31. Zabezpieczona aplikacja

Wyzwania:

  • nauczysz się jak budować bezpieczne aplikacje,
  • poznasz sposoby ochrony przed najpopularniejszymi zagrożeniami,
  • dowiesz się, jak wykorzystać w praktyce system autoryzacji Auth0.

Do tej pory uznawaliśmy, że wszyscy użytkownicy będą korzystać z naszych aplikacji poprawnie i zgodnie z założeniami, a jedynego zagrożenia spodziewaliśmy się ze strony... nas samych, na przykład w postaci błędów w kodzie.

Wszystkich użytkowników traktowaliśmy jako osoby, które są nastawione pozytywnie do naszej pracy i jedyną przeszkodą, którą przed nimi stawialiśmy, była przeważnie skromna walidacja formularzy. Tworzyliśmy ją jednak nie tyle w celu bronienia się przed jakąś złośliwą aktywnością, lecz bardziej przed zwykłą ludzką nieuwagą. Była to bardzo mała bariera bezpieczeństwa, tymczasem świat nie jest aż tak kolorowy.

Budując aplikację, musimy założyć, że przez cały czas będzie ona narażona na ataki hakerów, ale także nadużycia ze strony szeregowych odbiorców. Niebezpieczeństwo ze strony tej pierwszej grupy wydaje się oczywistością, ale jakim zagrożeniem są inni użytkownicy? Musisz wiedzieć, że próby "oszukania systemu" są dość nagminne, zwłaszcza w branży e-commerce. Istnieje grono użytkowników, które liczy np. na błędy przy naliczaniu rabatów albo ilości elementów. Możesz spodziewać się, że w przypadku posiadania sklepu internetowego, znajdzie się przynajmniej kilka osób, które będą próbowały korzystać z Twojej witryny w taki sposób, aby znaleźć jakąś lukę. Dlatego przy budowaniu aplikacji musimy być naprawdę uważni. Nie możemy liczyć, że wszyscy będą zachowywali się zgodnie z naszym planem. Bezpieczna aplikacja to taka, w której autor zakłada właśnie odwrotny scenariusz – taki, iż każdy chce znaleźć lukę w naszym systemie. Dopiero takie podejście, a następnie odpowiednia ochrona, może dać nam chociaż namiastkę bezpieczeństwa.

Właśnie tym zajmiemy się w niniejszym module. Opowiemy, czego należy się wystrzegać, jakie zagrożenia mogą na nas czyhać ze strony hakerów i jak się przed nimi bronić.

31.1. Dobre praktyki

Zanim zajmiemy się tematem obrony przed hakerami, postaramy się ugryźć temat od trochę łatwiejszej strony – jakie są dobre praktyki przy pisaniu aplikacji webowych?

Oczywiście nie poruszymy tu wszystkich możliwych kwestii. Bezpieczeństwo aplikacji jest tak szerokim tematem, że jeden moduł, a tym bardziej submoduł, to zdecydowanie za mało, aby przedstawić Ci wszystko. Niemniej jednak postaramy się zaprezentować najbardziej podstawowe zagadnienia, które powinny wystarczyć, by obronić się przynajmniej przed wścibskimi użytkownikami.

Ukrywaj wrażliwe dane

Może wydawać Ci się to oczywiste i nie do końca rozumiesz, dlaczego musimy o tym mówić. Z prostego powodu. Hasła do naszych kont są na tyle istotne, że powinniśmy ich strzec tak mocno, jak to możliwe. Czy jednak faktycznie zawsze to robiliśmy?

Wróćmy na chwilę do naszego przykładu z festiwalem muzycznym. W pewnym momencie łączyliśmy się ze zdalną bazą danych i wpisywaliśmy w Connection String login oraz hasło użytkownika, aby umożliwić komunikację. Dane te były zapisane w kodzie. Czy aby na pewno jest to bezpieczne? W teorii serwer to miejsce, do którego zwykły użytkownik nie ma dostępu, nie zapomnij jednak, że cały nasz projekt wrzucaliśmy na GitHub. Tam każdy może podejrzeć nie tylko kod klienta, ale także serwera, czyli również... nasze hasło.

Oczywiście możesz powiedzieć, że to tylko aplikacja testowa, więc nie ma to znaczenia. Jednak, czy aby na pewno nie zdarzyło Ci się już użyć gdzieś tego hasła? Hakerzy dobrze wiedzą, że większość internautów korzysta często z dokładnie takiej samej kombinacji znaków. Możesz więc narazić się na to, że ktoś będzie próbował wykorzystać znalezione dane logowania np. na Facebooku albo poczcie internetowej. Możliwy jest jeszcze gorszy scenariusz, bo co jeśli nie zmienisz hasła przed wypuszczeniem aplikacji w świat? Nawet jeśli uczynisz od tego momentu repo prywatnym, nie masz gwarancji, że ktoś już wcześniej nie zapisał tych danych. Takie zaniedbanie mogłoby Cię sporo kosztować.

Może wyjściem jest po prostu ukrywanie repo? W końcu GitHub zezwala na darmową opcję private. Problem jest jednak taki, że często chcemy nasz kod pokazywać, np. aby rekruter mógł zobaczyć, jak sprawnie poruszamy się w Node.js czy MongoDB. Jak sobie z tym poradzić?

Odpowiedź jest prosta. Kod może być przechowywany na otwartym repo, jednak bez udostępniania hasła. W takich przypadkach najlepiej wrzucić aplikację na Heroku, bo w ten sposób rekruter zobaczy ją w akcji bez potrzeby kompilacji, a trzymanie hasła na GitHubie nie będzie już konieczne.

Możesz jednak powiedzieć, że przecież Heroku też opiera się na repo. Wydaje się więc, że musielibyśmy jeden kod wrzucać na ten serwis, a inny do GitHuba. Nic bardziej mylnego, bowiem Heroku oferuje mechanizm zmiennych środowiskowych. Jeśli w naszym kodzie zapiszemy, że hasło ma być brane np. z env.dbpass, a następnie już w samej konfiguracji Heroku nadamy tej zmiennej odpowiednią wartość, to przy kompilacji, właśnie ona będzie wykorzystywana. Tym samym w kodzie widzimy tylko nazwę zmiennej, ale już na Heroku połączenie z bazą będzie zrealizowane poprawnie, przy wykorzystaniu prawdziwego hasła czy loginu.

Oczywiście, mechanizm zmiennych środowiskowych, możemy też wykorzystać do innych haseł czy informacji, a nie tylko dla danych logowania do bazy.

Uwaga!

Nie zapomnij jednak, że trzymanie kodu serwera na repo, nawet bez haseł, może być ryzykowne. Jeśli Twój skrypt ma jakieś luki, to haker będzie mógł je łatwo wykryć, a następnie zaatakować opublikowaną na Heroku aplikację, tak aby np. pobrać za jej pomocą dane z bazy.

Pamiętaj, że publikując kod na publicznym repo, wystawiasz go na takie niebezpieczeństwo. Jeśli to tylko aplikacja testowa, do nauki, nie musisz się niczego obawiać. W przypadku poważniejszych aplikacji miej to na uwadze.

Dobre hasło

Ukrywanie hasła nic nie da, jeśli jest ono po prostu słabe. Pamiętaj, że hakerzy często operują wydajnym sprzętem, na którym aplikacje do łamania szyfrów działają niezwykle sprawnie.

Jak możemy się przed tym bronić? Po prostu staraj się, żeby Twoje hasło było w miarę długie, nieprzewidywalne oraz zawierało różnorodne znaki. To oczywiście bardzo ogólna odpowiedź. Jest to jednak minimum, którego powinniśmy się trzymać. Ciężko stworzyć dokładną listę wymagań, gdyż te często się zmieniają, np. do niedawna bardzo popularną praktyką było wymuszanie na użytkownikach cyklicznej zmiany hasła. Tymczasem firma Microsoft twierdzi, w oparciu o swoje doświadczenia, że takie podejście czyni więcej złego niż dobrego. Użytkownicy z lenistwa i tak wybierają prawie identyczne hasła, zmieniając tylko jeden z ostatnich znaków, dlatego też np. w swoich nowych wytycznych dla Office 365 technologiczny gigant zaleca... odejście od tego pomysłu.

Ćwiczenie

W ramach praktyki spróbuj wykorzystać nową wiedzę. Wróć do naszej festiwalowej aplikacji i postaraj się ukryć dane logowania do bazy w zmiennych konfiguracyjnych Heroku. Możesz ustawić je w konsoli albo w panelu administracyjnym, wystarczy wejść w zakładkę opcji swojej aplikacji. Dokładną instrukcję znajdziesz pod tym linkiem.

Do zmiennych mamy dostęp w obiekcie process.env, więc jeśli dodasz nową np. o nazwie test, to Twoja aplikacja będzie miała do niej wgląd pod process.env.test.

W efekcie, przy połączeniu z bazą zdalną, aplikacja powinna korzystać ze zmiennych konfiguracyjnych i wciąż działać poprawnie. Dzięki temu na naszym repo nie będziemy musieli już przechowywać tak wrażliwych danych.

Nie ufaj klientowi

Ten podrozdział powinien nazywać się raczej – nie ufaj temu, co dostarcza Ci klient.

Przypuśćmy, że tworzymy panel administracyjny jakiejś aplikacji i zajmujemy się właśnie implementacją formularza do dodawania nowych użytkowników. Nie jest to nic wielkiego, załóżmy, że mamy dosłownie trzy pola – login, password i role. Nas najbardziej interesuje to ostatnie, które pozwala na wybranie opcji user albo admin. Nadana rola definiuje oczywiście poziom uprawnień, przy czym z panelu administracyjnego mogą korzystać zarówno użytkownicy o statusie admin, jak i user. Obie grupy mają prawo dodawać innych za pomocą naszego formularza, z tym zastrzeżeniem, że userzy mogą dołączać tylko tych, którzy mają tę samą rolę (user), a admini mogą dodawać również administratorów.

Nie będziemy wnikać, jak dokładnie zbudowany jest ten formularz. Załóżmy jednak, że korzystając z danych otrzymanych od serwera, klient zawsze wie, jaką rolę ma aktualnie zalogowany użytkownik. Na bazie tych uprawnień pozwala na wybranie jednej z ról (jeśli jesteśmy administratorem) albo z góry ustawia formularz jako user. Do tego mamy bardzo dokładną walidację i np. za krótkie hasło albo za długi login nie pozwala nawet na wysłanie formularza.

Skoro zakładamy, że klient połączy się z serwerem tylko wtedy, kiedy dane są dobre, to wydaje się, że ponowna walidacja na serwerze jest zbędna. Nasz endpoint mógłby więc wyglądać tak:

app.post('/user', (req, res) => {

  try {
    const { login, password, role } = req.body;

    const user = new User({ login, password, role });
    await user.save();
    res.status(201).json({ message: 'OK' });

  }
  catch(err) {
    res.status(500).json({ message: err })
  }
});

Czy to aby na pewno dobry pomysł?

Pamiętaj, że jeśli serwer API jest dostępny dla naszego klienta, to równie dobrze można się z nim połączyć z innego. W takiej sytuacji ktoś mógłby, nawet za pomocą Postmana, wysłać następujący request, oczywiście z pominięciem naszej aplikacji klienta:

POST example.com/api/user

{
  "login": "Tooooo long login",
  "password": "123",
  "role": "admin"
}

Co by to dało atakującemu? Stworzyłby nowe konto, które miałoby uprawnienia admina, nawet jeśli sam nie miał nawet statusu użytkownika. Przy okazji złamałby jeszcze zasady co do trudności hasła i maksymalnej długości loginu, bo w końcu serwer sam już tego nie weryfikuje. Co więcej, backend nie sprawdza nawet, czy ktoś jest zalogowany.

Skąd atakujący wiedziałby jak ma skonstruować request? Wystarczy, że chociaż przez chwilę był użytkownikiem naszej aplikacji i zajrzał do zakładki "Network" w narzędziach developerskich.

Jak możemy się przed tym ustrzec? Najprostsze jest blokowanie połączenia z zewnątrz.

Dotychczas używaliśmy middleware cors przeważnie po to, by pozwalać na wszelką łączność z zewnątrz, ale możemy go wykorzystać także w przeciwnej sytuacji. Aby ograniczyć połączenia AJAX tylko do tych wysyłanych z naszej witryny, wystarczy wywołać cors() z odpowiednią opcją:

app.use(cors({
  origin: 'http://example.com'
}));

Możemy również zezwolić na połączenia np. z dwóch albo trzech wspieranych adresów.

app.use(cors({
  origin: function(origin, callback){

    if(!origin) return callback(null, true);
    if(allowedOrigins.indexOf(origin) === -1){
      const msg = 'The CORS policy for this site does not allow external access...';
      return callback(new Error(msg), false);
    }
    return callback(null, true);
  }
}));

W taki sposób możemy zablokować kilku domorosłych hakerów, warto jednak pamiętać, że niestety nie jest to rozwiązanie idealne. Wciąż istnieje możliwość skonstruowania requestu, chociażby za pomocą funkcji curl w konsoli i stworzenia zapytania z ręcznie ustawionym origin. Tym samym bardziej wprawny atakujący może udawać, że połączenie pochodzi z tej samej witryny...

cors jest więc jakimś buforem bezpieczeństwa, ale jednak da się go obejść. Rozwiązanie może być w takiej sytuacji tylko jedno – musimy sprawdzać również na serwerze, kim jest użytkownik i jakie dane wprowadza. Tutaj przechodzimy do kolejnej dobrej praktyki.

Zawsze waliduj dane również na serwerze

Przed chwilą pisaliśmy, że nie możemy ufać walidacji po stronie klienta, ponieważ użytkownik może obejść ją wysyłając requesty z innego źródła. Wiemy też, że cors nie da nam stuprocentowej gwarancji, że wszystkie takie próby będą zablokowane. Niemniej jednak, nawet gdyby było to możliwe i mielibyśmy pewność, że serwer przyjmowałby dane tylko od naszego własnego klienta, to i tak nie moglibyśmy czuć się bezpiecznie. Dlaczego?

Na tym etapie kursu wiesz już jak duże zmiany możemy wprowadzać, korzystając z narzędzi developerskich i podglądu strony, na której aktualnie jesteśmy. Co z tego, że pole tekstowe ma atrybut required? Możemy wejść do inspektora i to zmienić. Cóż, że jakiś <select> będzie dla nas zablokowany (disabled)? Jesteśmy w stanie wyłączyć ten atrybut w konsoli. Problemem nie jest nawet walidacja w JSie, bo ten kod też zmodyfikujemy "w locie". Ostatecznie możemy też po prostu wyłączyć obsługę JavaScriptu w przeglądarce.

Spójrz tylko na poniższy przykład:

image

Mamy tutaj formularz, który przypomina ten wspomniany już w submodule. Zauważ, że w HTML-u ustaliliśmy, iż pola "E-mail" i "Hasło" są wymagane. Dodatkowo aktualnie zalogowany użytkownik nie ma prawa wskazać innej roli niż user. Pamiętamy bowiem, że w założeniu funkcję admin może wybrać tylko osoba, która sama jest administratorem.

Okazuje się jednak, że taka walidacja jest dziecinnie łatwa do ominięcia. Wystarczyło wejść do inspektora, zmienić kilka elementów i nagle formularz może zostać wysłany mimo kompletnego pogwałcenia warunków walidacji. Zauważ, że w powyższym przykładzie bez problemu wysłaliśmy go bez wypełnienia pola "E-mail" czy "Hasło", a do tego, mimo braku uprawnień, wybraliśmy rolę nowego użytkownika jako admin. Jak jest z tego wniosek? Nie ufaj walidacji po stronie klienta.

Nieważne jak bardzo wyrafinowana będzie taka kontrola, zawsze znajdzie się ktoś, kto łatwo ją wyłączy. Nigdy nie możemy wierzyć, że to, co dostajemy od klienta, faktycznie jest poprawne. Frontendową walidację traktuj raczej jako usprawnienie UX (User Experience), które ma być pomocą dla użytkownika. W końcu dzięki takiej funkcjonalności może od razu przekonać się, co robi nie tak, zamiast czekać na reakcję serwera na wysyłane dane. Pod względem bezpieczeństwa o wiele ważniejsza jest walidacja po stronie serwera. Użytkownik nie jest w stanie jej wyłączyć i co najwyżej może szukać luk, aby ją jakoś oszukać.

Podsumowując – walidacja po stronie klienta to tylko usprawnienie UX, zawsze weryfikuj dane również drugi raz, po stronie serwera.

Jak mógłby wyglądać nasz wcześniejszy przykład endpointu po modyfikacjach?

app.post('/user', (req, res) => {

  try {
    const { login, password, role } = req.body;

    if(!login || !password || !role) throw new Error('Invalid data');
    else if(!userLogged())
    else {
      const user = new User({ login, password, role });
      await user.save();
      res.status(201).json({ message: 'OK' });
    }

  }
  catch(err) {
    res.status(500).json({ message: err })
  }
});

Zapewne udało Ci się zauważyć, że w samym endpoincie nie sprawdzamy poprawności danych, czyli np. czy login nie jest za długi. To dlatego, że akurat takie reguły walidacji można wprowadzić w samym schemacie modelu. Robiliśmy to już w poprzednich modułach.

Obsługuj potencjalne błędy

Bardzo często pracujemy przy użyciu zewnętrznych bibliotek, a te mogą mieć różne systemy powiadamiania o błędach. Niektóre "wyrzucają" po prostu Error, inne wypisują dokładne komunikaty (np. Couldn't connect to cluster...), a jeszcze inne same pozwalają nam zadecydować, jak mają zachowywać się w przypadku problemu. Różne nieprawidłowości mogą sygnalizować również niektóre z metod wbudowanych w JS-a. Wniosek jest jednak jeden – w przypadku błędu, użytkownik może zobaczyć komunikat, który niekoniecznie chcielibyśmy mu pokazywać.

Idea, aby informować użytkownika o błędzie oczywiście nie jest zła, niemniej jednak powinniśmy to robić w sposób dla niego zrozumiały. Często komunikaty są bardzo długie i enigmatyczne. Na przykład błąd #23535, Error: Couldn\'t connect to cluster. Problem on line 5, cluster-connect.js. [ERR_WRONG_USERNAME] dla nas byłby bardzo pomocy, bo dość dokładnie wskazywałby miejsce problemu, jednak czy użytkownika naprawdę to interesuje? Dla niego lepsza byłaby informacja w stylu Couldn't connect to DB... Try again – krótsza, ale nie zawierająca zbędnych technicznych szczegółów.

Inna sprawa, że komunikaty wyrzucane przez skrypty mogą też czasem ujawniać wrażliwe dane, na przykład przy łączeniu się z bazą danych: Err: DB connection error. Couldn't connect with [username]=JohnDoe and [password]=admin1. Taka informacja mogłaby pojawić się nawet po przypadkowym przeciążeniu serwera bazy danych, a naraziłby nas na ogromne ryzyko.

Podsumowując, nie chcemy, aby JS "wyrzucał" użytkownikowi domyślną treść błędów, ponieważ jest ona często niezrozumiała, a może nas narazić na wyciek wrażliwych danych.

Czy to oznacza, że musimy w ogóle zrezygnować z wyświetlania komunikatów użytkownikowi? Nie. Powinniśmy jednak wyłapywać błąd, który wskazuje nam dany skrypt i sami decydować, co pokażemy. Na przykład, gdy wykryjemy problem z połączeniem z bazą, to zamiast pozwolić JS-owi na wyrzucenie pełnego komunikatu, będziemy wypisywać coś sami. Taki efekt możemy osiągnąć przy użyciu bloku try ... catch, który jak zapewne pamiętasz, w przypadku wykrycia jakiegoś błędu, uruchamia kod umieszczony w catch (catch to z ang. łapać).

Podsumowując, w przypadku kodu, który może spowodować problem, powinniśmy wyłapywać potencjalne błędy, a następnie pokazywać zrozumiały dla użytkownika komunikat na ich temat. Oczywiście robimy to, o ile jest w ogóle sens o tym informować. Mogą zdarzyć się bowiem sytuacje, w których mimo problemów, aplikacja będzie działać normalnie, np. jeśli błąd pojawił się w module, który zapisuje statystki odwiedzin strony. Czy jego awaria powinna w ogóle interesować użytkownika? Raczej nie.

Z try ... catch już korzystaliśmy, ale dla przypomnienia przedstawiamy jeszcze jeden przykład.

connectToDB('JohnDoe', 'admin1');

Załóżmy, że connectToDB to funkcja, która stara się łączyć z bazą danych. W przypadku błędu oczywiście pokaże ona od razu jakiś komunikat i na razie nie mamy nad nim kontroli.

Gdybyśmy jednak skorzystali z bloku try ... catch, to ten błąd nie byłby od razu zwracany. Zamiast tego catch przyjmowałby go jako err, a my moglibyśmy decydować co z nim zrobić dalej – pokazać, zignorować, czy wyświetlić własny komunikat.

try {
  connectToDB('JohnDoe', 'admin1');
} catch(err) {
  console.error(err);
}

albo

try {
  connectToDB('JohnDoe', 'admin1');
} catch(err) {
  console.log('Couldn\'t connect to db...');
}

Przydatne komunikaty

Dokładne komunikaty o błędach mogą być też bardzo pożyteczne. Użytkownik ich nie potrzebuje, ale my, jako twórcy kodu, często chcemy korzystać z ich pomocy. W końcu może nas to bardzo szybko prowadzić do rozwiązania. Dlatego też całkowite ich ignorowanie i wypisywanie własnych prostych komunikatów nie zawsze jest dobre.

Optymalnym rozwiązaniem może być pokazywanie różnych komunikatów zależnie od tego, w jaki sposób uruchomiliśmy nasz kod, opierając się np. na jakiejś zmiennej z process.env.

try {
  connectToDB('JohnDoe', 'admin1');
} catch(err) {
  if(process.env.debug === true) console.log(err);
  else console.log('Couldn\'t connect to db...');
}

W powyższym kodzie ustawiliśmy wartość true dla zmiennej debug. W takim przypadku, jeśli pojawi się błąd, dostaniemy jego dokładną treść. Gdyby jednak skrypt był uruchamiany standardowo, bez debug ustawionego na true, to użytkownik przy błędzie zobaczyłby znacznie przyjaźniejszy komunikat.

Dzięki temu możemy w razie problemu uruchamiać nasze aplikacje w taki sposób, aby być informowanym na bieżąco o szczegółowych błędach, a sam użytkownik korzystający z "normalnej" wersji, wciąż widziałby tylko to, co dla niego zaplanowaliśmy.

Dokładnie filtruj wprowadzane dane

Często sprawdzenie samego typu danych nie wystarcza, bo np. string może być zarówno zwykłym tekstem (Lorem Ipsum), kodem HTML (<p>Lorem Ipsum</p>), jak i wyrażeniem regularnym (/^[A-Z]{3}$/)). Weryfikowanie wyłącznie typu może więc przynieść opłakane skutki.

Aby lepiej Ci to zobrazować, postaramy się to przedstawić na przykładzie.

Powiedzmy, że na swojej stronie masz system komentarzy. Co istotne, chcesz aby były one prostymi wiadomościami tekstowymi, ewentualnie z pogrubieniem lub pochyleniem tekstu. Do wpisywania danych korzystasz z formularza wraz z elementem <textarea>. Sam formularz jest jednak jeszcze obsługiwany przez jakiś dodatkowy plugin, który pozwala właśnie na ustawienie pogrubienia czy też pochylenia. Po wysłaniu tego formularza Twój kod na serwerze odbiera treść komentarza, sprawdza, czy jest stringiem i jeśli tak, to dodaje go do bazy danych.

Kod na serwerze mógłby wyglądać mniej więcej tak:

app.post('/comment', (req, res) => {
  const { text } = req.body;

  try {
    if(!text || !text.length) throw new Error('"text" param is invalid!');
    else {
      const comment = new Comment();
      comment.text = text;
      await comment.save();
      res.status(201).json({ message: 'OK' });
    }
  } catch(err) {
    res.status(500).json({ message: 'Something went wrong...' });
  }
});

Nie sprawdzamy tutaj typu bezpośrednio w endpoincie, bo jak zapewne pamiętasz, jest on ustalany w schemacie modelu. Jeśli więc typ danych byłby inny niż string, to Mongoose i tak zwróciłby nam błąd i zwyczajnie nie pozwoliłby na dodanie nowego dokumentu.

Oprócz tego jeden z komponentów na stronie pobiera te dane i wyświetla w następujący sposób:

const comments = ({ comments }) => (
  <ul>{comments.map(comment => <li key={comment.id}>{comment.text}</li>)}</ul>
);

Nic specjalnego, ma on po prostu pokazywać nasze komentarze w formie listy z elementami li. Oczywiście zakładamy, że w comment.text mogą być wykorzystywane proste tagi HTML (do pogrubiania albo pochylania tekstu). Dlatego też powinniśmy "powiedzieć" Reactowi, aby traktował atrybut text jako możliwy kod HTML.

const comments = ({ comments }) => (
  <ul>{comments.map(comment => <li key={comment.id} dangerouslySetInnerHTML={{ __html: comment.text }}></li>)}</ul>
);

I teraz, dopóki wszyscy użytkownicy będą poprawnie korzystali z naszego formularza, wszystko będzie dobrze. W naszej bazie będą zapisywać się dokumenty np. o takiej treści:

- <strong>Lorem</strong> Ipsum
- <em>Lorem</em> Ipsum
- Lorem Ipsum

A gdy będziemy je pobierać i pokazywać w HTML-u, to też zobaczymy poprawne komentarze:

  • Lorem Ipsum
  • Lorem Ipsum
  • Lorem Ipsum

Takie było założenie. Teraz jednak wyobraź sobie, że jakiś złośliwy użytkownik specjalnie wyłączył plugin do obsługi textarea i sam ręcznie, w inspektorze, wpisał taką treść:

<h1>Haha, my comment is the most important!</h1>

Gdyby następnie wysłał formularz do serwera, jak zareaguje na to nasz endpoint? Sprawdzi, czy text istnieje, czy nie jest pusty, oraz czy to w ogóle string. Mongoose uzna, że tak, więc zapisze go do bazy danych. Przez to, gdybyśmy odwiedzili po takim ataku naszą stronę, okazałoby się, że jeden z komentarzy jest nienaturalnie duży.

  • Lorem Ipsum
  • Lorem Ipsum
  • Lorem Ipsum
  • Haha, my comment is the most important!

Brak dokładnej walidacji i przefiltrowania otrzymanego stringu spowodował łatwą do wykorzystania lukę, która oczywiście daje znacznie większe pole do popisu. Wyobraź sobie, że atakujący dodał taki komentarz:

<div style="background: black; width: 100%; height: 100%; position: fixed; top: 0; left: 0; color: #fff">This website has been hacked...</div>

Znowu serwer przyjmie, że to string i zapisze go do bazy danych. Tymczasem, kiedy nasz klient wyrenderuje taki "komentarz", to div, który się pojawi, zakryje całą stronę...

image

Mamy tu więc kilka wniosków. Po pierwsze słowo dangerously w nazwie funkcji dangerouslySetInnerHTML nie wzięło się znikąd. Renderując HTML pobrany z zewnątrz, powinniśmy być w stu procentach pewni, co do jego poprawności.

Po drugie, nawet mały błąd, czy luka w walidacji może pozwolić atakującemu na spektakularnie wyglądające "popsucie" naszej aplikacji. Zauważ, że mimo całkiem sensownej kontroli na serwerze, a więc sprawdzenia, czy dane zostały otrzymane oraz czy ich typ jest dobry, nasz bariera ochronna okazała się za słaba. To pokazuje, jak bardzo uważni musimy być podczas pisania kodu.

Po trzecie, już w formie podsumowania – przy walidacji danych sprawdzaj nie tylko typ, ale też jak ta wartość jest zbudowana. Jeśli mamy otrzymać np. nazwy kolorów po angielsku i mają być one pisane małą literą, to sprawdzajmy, czy wprowadzany tekst faktycznie spełnia te wymagania. Jeśli wiemy, że ma to być jedna z kilku ról, np. admin albo user, to sprawdźmy, czy to faktycznie jedna z nich. Musimy być jak najdokładniejsi. Przy tworzeniu walidacji, zakładaj z góry wszystkie możliwe złe scenariusze. Spodziewaj się, że atakujący postara się znaleźć wszelkie luki i nieścisłości.

Dobrze, ale jak w takim razie poradzić sobie z naszym problemem? Tego typu zagadnienia rozwiązujemy zazwyczaj przy użyciu wyrażeń regularnych.

W naszym przypadku mogłoby to wyglądać następująco:

app.post('/comment', (req, res) => {
  const { text } = req.body;

  try {
    const pattern = new RegExp(/(<\s*(strong|em)*>(([A-z]|\s)*)<\s*\/\s*(strong|em)>)|(([A-z]|\s|\.)*)/, 'g');
    const textMatched = text.match(pattern).join('');
    if(textMatched.length < text.length) throw new Error('Invalid characters...');
  ...

Jak to działa?

const pattern = new RegExp(/(<\s*(strong|em)*>(([A-z]|\s)*)<\s*\/\s*(strong|em)>)|(([A-z]|\s|\.)*)/, 'g');

Najpierw przygotowujemy odpowiedni pattern. Na pierwszy rzut oka może wydawać się on bardzo skomplikowany, ale jego idea jest prosta. Powinien dopasować się do tagów <strong></strong> lub <em></em>, o ile wewnątrz nich znajduje się tylko tekst albo spacje ((([A-z]|\s)*)). Odpowiada za to cała pierwsza część – (<\s*(strong|em)*>(([A-z]|\s)*)<\s*\/\s*(strong|em)>). Oprócz tego pattern dopasuje się również do każdego pozostałego tekstu, spacji lub kropki (w końcu komentarz może być zbudowany z kilku zdań). Pamiętaj, że < czy > to już nie litery, więc ta część nie bierze pod uwagę żadnych tagów, odpowiada za to fragment (([A-z]|\s|\.)*). Zatem pattern dopasuje się do tagów <strong> i <em> albo zwykłego tekstu.

const textMatched = text.match(pattern).join('');

W kolejnej linijce staramy się sprawdzić, ile tekstu w text będzie zgodne z naszym patternem. W teorii powinniśmy liczyć na to, że od klienta otrzymamy właściwe dane, więc tak naprawdę do założeń powinien pasować cały tekst.

if(textMatched.length < text.length) throw new Error('Invalid characters...');

Na końcu sprawdzamy więc, czy text początkowy jest zgodny z tym, co otrzymaliśmy. Jak mówiliśmy już wcześniej, jeśli text był poprawny, to w teorii textMatched powinien być dokładnie tak sam, a więc obie dane powinny mieć identyczną długość. Jeśli nie mają, świadczy to o tym, że część tekstu musiała nie pasować do naszego patternu, więc ktoś starał się wprowadzić do naszego systemu nieprawidłowe dane. Stąd też zwracamy wtedy adekwatny komunikat.

Na końcu krótka symulacja.

Jeśli text byłby równy <strong>Lorem</strong> Ipsum, to text.match(pattern) zwróciłoby nam następującą zawartość:

['<strong>Lorem</strong>', ' ', 'Ipsum'];

Udałoby się bowiem znaleźć trzy pasujące do patternu elementy. Po ich złączeniu (metoda join) otrzymalibyśmy:

'<strong>Lorem</strong> Ipsum'

Porównując nowy "dopasowany" do patternu tekst ze starym, dowiedzielibyśmy się, że ich długość jest taka sama, a więc nie było w oryginale elementu niepasującego.

Gdyby jednak tekst startowy wyglądał np. tak <strong>Lorem</strong> <h1>Test</h1>Ipsum, to po próbie dopasowania w textMatched otrzymalibyśmy:

'<strong>Lorem</strong> h1Testh1Ipsum'

Metoda match przepuściłaby same nazwy tagów (w końcu to zwykły tekst), ale to nie problem, bowiem nie dopasowałaby ani < ani >. Zatem textMatched byłby trochę inny od text (krótszy) i nasz serwer wyrzuciłby błąd.

W podobny sposób moglibyśmy testować również kompletnie inne przypadki, np. czy otrzymaliśmy od klienta tekst w formacie imię nazwisko. Co ciekawe, walidację z użyciem wyrażeń regularnych można "przykleić" również do samych modeli Mongoose. Wspominaliśmy o tym w poprzednich modułach. Dzięki temu nie musielibyśmy przeprowadzać tego procesu w samym endpoincie, bo mielibyśmy pewność, że Mongoose zrobi to za nas, przy próbie zapisania dokumentu do bazy.

Niebezpieczny HTML

Warto dodać, że najczęściej staramy się w ogóle nie zapisywać kodu HTML do bazy danych, a tylko tekst. Dlatego też samą treść najczęściej zwyczajnie się escape'uje przed dodaniem do bazy. Proces ten polega na podmianie cudzysłów, apostrofów oraz znaków &, < i > na odpowiadający im zapis kodowy.

Poniżej przedstawiamy przykładową funkcję, która mogłaby się tym zajmować:

function escape(html) {
  return html.replace(/&/g, "&amp;")
             .replace(/</g, "&lt;")
             .replace(/>/g, "&gt;")
             .replace(/"/g, "&quot;")
             .replace(/'/g, "&#039;");
}

Co istotne, tak skonwertowany kod jest już w pełni bezpieczny. Zapewne pamiętasz bowiem, że taki zapis (np. &lt;h1&gt; &lt;/h1&gt;) nie będzie odbierany przez przeglądarkę jako nagłówek <h1></h1>, tylko tekst <h1></h1>.

Korzystaj z Mongo-sanitize

Wyobraź sobie, że przygotowaliśmy następujący endpoint odpowiadający za logowanie:

app.post('/login', (req,res) => {

  try {
    const loggedUser = await User.findOne({ email: req.body.email, password: req.body.password });
    if(loggedUser) res.send({ message: 'Logged!' });
    else res.status(404).json({ message: 'Not found' });
  }
  catch(err) {
    res.status(500).json({ message: 'Something went wrong' });
  }

});

Czy wszystko jest tutaj w porządku? Wydaje się, że tak. Staramy się znaleźć takiego użytkownika, którego e-mail i hasło będzie zgodne z tym, co otrzymaliśmy od klienta. Jeśli istnieje, możemy rozpocząć nową sesję. Oczywiście w naszym przykładzie tylko zwracamy komunikat o sukcesie, a w normalnej sytuacji jednak przeprowadzalibyśmy dalej proces inicjacji sesji dla tego użytkownika.

No dobrze, ale wracając do tematu – wydaje się, że wszystko jest w porządku. No cóż... nie jest. Wyobraź sobie, że użytkownik pomija aplikację klienta i sam generuje odpowiedni request (wiemy, że to możliwe). Jako dane w body podaje coś takiego:

body: {
  "email": { "$ne": "123" },
  "password": { "$ne": "123" },
}

Jak zareagowałby na to nasz serwer? Skoro req.body.email to { "$ne": "123" }, a req.body.password to { "$ne": "123" }, w efekcie findOne otrzymałoby następujący warunek:

User.findOne({ email: { "$ne": "123" }, password: { "$ne": "123" } };

Pewnie domyślasz się, jakie miałoby to skutki – chcemy znaleźć jeden dokument, którego email i password są inne niż 123. Cóż... taki warunek spełnić bardzo łatwo. W końcu najprawdopodobniej żaden z użytkowników nie korzysta z hasła 123, a już tym bardziej nie posiada takiego e-maila. Zapewne Mongoose zwróciłaby nam tu pierwszego użytkownika z kolekcji.

Jaki mamy efekt? Atakujący nie musiał znać loginu ani hasła żadnego z użytkowników, a udało mu się zalogować na jednego z nich. Jeśli weźmiemy pod uwagę, że bardzo często pierwszym użytkownikiem jest administrator, mamy jeszcze większy problem. Atakujący otrzymałby bowiem dostęp do konta z bardzo dużymi uprawnieniami!

Użycie operatora $eq

Pierwszą linią oporu powinno być użycie operatora $eq.

const loggedUser = await User.findOne({ email: { $eq: req.body.email }, password: { $eq: req.body.password }});

Daje nam on gwarancję, że sprawdzimy, czy dany atrybut jest równy dokładnie temu, czego oczekujemy. Nie możemy jako wartości przekazać kolejnego operatora. Gdybyśmy więc nawet dalej pozwalali na wykonywanie requestów z takim body jak wcześniej, to otrzymany warunek

User.findOne({ email: { $gt: {"$ne": "123" }}, password: { $gt: { "$ne": "123" }} };

nie byłby dla nas groźny. $eq po prostu nie znalazłoby elementów, których atrybut byłby równy operatorowi.

Odpowiednio parsuj otrzymywane dane

Pamiętaj, że w ogóle nie powinniśmy doprowadzić do sytuacji, gdzie w Twoim req.body są treści, które mogą być niebezpieczne. Skoro oczekujemy na dwa stringi, to sprawdźmy, czy email i password faktycznie nimi są. A jeśli tak, to ustalmy również, czy nie mają jakichś niepożądanych znaków, np. $ czy klamerki. Podobną rzecz robiliśmy już w przypadku otrzymywania danych HTML. Dobrą praktyką jest ich escape'owanie. Warto byłoby odpowiednio parsować dane, które otrzymujemy.

Nie musisz jednak przygotowywać odpowiedniej funkcji od zera. Na rynku istnieje już bowiem paczka mongo-sanitize oferującą gotową funkcję sanitize. Jej zadaniem jest właśnie parsowanie wybranych danych. Działa ona w taki sposób, że pozbywa się niebezpiecznych znaków i zwraca nam wartość, której nie musimy się już obawiać i możemy spokojnie wykorzystać w dalszym kodzie.

Ćwiczenie

Spróbuj wykorzystać ten pakiet w naszym projekcie strony festiwalu.

Najpierw pobierz odpowiednią paczkę:

yarn add mongo-sanitize@1.0.1

Następnie spróbuj wykorzystać ją w jednym z endpointów typu POST. Instrukcję znajdziesz pod tym linkiem.

Uważaj na pakiety zależne

Idea korzystania w naszej pracy z zewnętrznych paczek towarzyszy nam od początku kursu. To dobra praktyka – skracamy czas produkcji, nie wymyślamy koła na nowo, a do tego niektóre z nich narzucają wykorzystywanie dobrych nawyków programistycznych. Niemniej jednak pamiętajmy, że nie każda paczka jest dobra. Istnieją takie, które są na rynku od lat, mają tylko pozytywne opinie, a do tego autorzy ciągle je wspierają i aktualizują. Trafiają się również takie, które są ich przeciwieństwem – mogą być słabo napisane, czy powodować błędy w niespodziewanych momentach. Jest jeszcze inna możliwość. Paczka sama w sobie nie jest zła, ale czas mija, nie była aktualizowana i w tej chwili stała się dziurawa i podatna na ataki. Pamiętaj, że zależności są często integralną częścią Twojej aplikacji i jeśli mają luki, Twój skrypt też nie jest w stu procentach bezpieczny.

Podążaj za sprawdzonymi rozwiązaniami

Staraj się korzystać z popularnych i cenionych paczek. Zwracaj uwagę na ilość wydań, pobrań oraz datę ostatniej aktualizacji. To bardzo ważne, aby dany pakiet był wciąż wspierany. Wtedy nawet w przypadku wykrycia luk, powinny być szybko załatane.

Unikaj paczek, które sam autor opisuje jako deprecated (przestarzałe). Nawet jeśli wydaje Ci się, że dana paczka jest idealna do Twoich potrzeb, to całkiem możliwe, że w wielu przypadkach nie będzie poprawnie współpracować z Twoim kodem. Do tego jest spora szansa, że ma jakieś luki bezpieczeństwa. Pamiętaj, że IT to niezwykle dynamiczna branża.

Aktualizuj swoje pakiety zależne

W razie wykrycia luk, autorzy paczek starają się je łatać. Aby Twoja aplikacja korzystała z tych zmian (fiksów), musisz je w razie potrzeby aktualizować. Niestety często posiadamy ich tak dużo, że ręcznie sprawdzanie każdej z nich byłoby dość karkołomnym zadaniem. Dobrym wyjściem może więc być skorzystanie z narzędzia o nazwie Snyk. Automatycznie sprawdza ono repozytorium GH i raportuje na temat ewentualnych niebezpieczeństw. Możesz z niego skorzystać online pod tym linkiem.

Istnieje też paczka snyk do terminala, która pozwala na sprawdzanie Twoich projektów lokalnie.

Ćwiczenie

Pobierz snyk globalnie:

yarn add snyk@1.235.0 -g

W ten sposób paczka będzie mogła być wykorzystywana w różnych projektach – wtedy, kiedy zapragniemy sprawdzić, jak wygląda sytuacja z naszymi pakietami.

Aby jednak korzystać z niej lokalnie, musisz jeszcze założyć konto w serwisie snyk.io.

Następnie wejdź do folderu ze swoim projektem strony festiwalu muzycznego i uruchom polecenie:

snyk auth

Pozwoli Ci to zalogować się do swojego konta.

Następnie wpisz komendę:

snyk test

Sprawdzi ona wszystkie pakiety zależne i odpowiednio zaaportuje Ci rezultat testów.

Znajdź i napraw

Oprócz komendy snyk test, w paczce istnieje również snyk wizard, dzięki której skorzystamy z bardziej zaawansowanej wersji narzędzia. Nie tylko wskaże ona ewentualne luki w bezpieczeństwie, ale pozwoli też na ich automatyczne rozwiązanie.

Korzystaj z paczki Helmet

Oprócz powyższych dobrych praktyk standardem jest użycie paczki helmet. Jej zadanie polega na odpowiednim ustawieniu nagłówków HTTP, tak aby nasz serwer był mniej podatny na ataki. Opis wszystkich funkcjonalności znajdziesz na stronie https://helmetjs.github.io/. Potraktuj tę paczkę jako podstawę i coś, co w miarę możliwości utrudni ataki, które mogłyby wykorzystywać słabości ustawień nagłówków.

Uwaga!

Często programiści, zwłaszcza początkujący, traktują helmet() jako coś, co w pojedynkę obroni naszą aplikację. Nie do końca tak jest. To narzędzie, od którego warto zacząć, ale nie wystarczy, aby aplikacja była bezpieczna.

Przede wszystkim musimy zadbać o dobrze napisany, odporny kod, czego tyczyły się wcześniejszy podrozdziały. Pamiętaj o tym!

Ćwiczenie

W ramach ćwiczenie postaraj się wykorzystać helmet w naszej aplikacji festiwalu. Jego użycie jest bardzo proste.

Wystarczy pobrać tę paczkę:

yarn add helmet@3.21.1

Następnie zaimportować i użyć jako middleware:

const helmet = require('helmet');

...

app.use(helmet());

Podsumowanie

Czy podążanie za dobrymi praktykami z tego submodułu wystarczy? Niestety nie zawsze. Im bardziej rozbudowana i skomplikowana aplikacja, tym więcej luk może się w niej pojawić. Jeśli jej rola jest newralgiczna (np. tworzymy stronę banku), będziemy narażeni na więcej ataków, a te mogą być często niezwykle wyrafinowane. Nie jesteśmy więc w stanie przygotować się na zagrożenie w stu procentach. Nawet jeśli wydaje nam się, że wzięliśmy pod uwagę wszystko, może okazać się, że atakujący wymyśli jakiś nowy sposób, np. złamie używany przez nasz szyfr kodowania.

Mimo to musimy starać się maksymalnie zniwelować niebezpieczeństwo. Zabezpieczanie aplikacji to bardzo ważna sprawa, a branża ciągle się rozwija. Ważne systemy muszą być na bieżąco analizowane i w razie potrzeby korygowane, aby radzić sobie z nowymi formami ataków. Dopiero takie podejście daje nam relatywnie dużą gwarancję co do bezpieczeństwa.

Zadanie: koniec ćwiczeń

Wyślij swojemu Mentorowi link do aktualnego repo festiwalu muzycznego. Aplikacja ta powinna teraz zawierać wszystkie zmiany, które wprowadzono podczas ćwiczeń w tym submodule.

31.2. Neutralizacja zagrożeń

W pierwszym submodule mówiliśmy o bezpieczeństwie i o czym warto pamiętać przy pisaniu naszego kodu. Teraz podejdziemy do tematu od drugiej strony i opowiemy, jakie są najpopularniejsze typy zagrożeń oraz jak się przed nimi bronić.

Co roku organizacja OWASP (Open Web Application Security Project), zajmująca się bezpieczeństwem aplikacji internetowych, sporządza ranking najbardziej powszechnych zagrożeń, czyli tych, których witryny doświadczają najczęściej. Lista jest budowana w oparciu o dużą ilość badań, ale też ankiety wypełniane przez ludzi pracujących w branży. Omówimy ją sobie w tym submodule.

W najnowszym wydaniu raportu lista ta wygląda następująco:

  1. Injection
  2. Broken Authentication
  3. Sensitive Data Exposure
  4. XML External Entities (XXE)
  5. Broken Access Control
  6. Security Misconfigurations
  7. Cross Site Scripting (XSS)
  8. Insecure Deserialization
  9. Using Components with Known Vulnerabilities
  10. Insufficient Logging and Monitoring

Injection

Pod hasłem Injection kryje się zwyczajnie idea "wstrzyknięcia" niebezpiecznego kodu. Akurat tym rodzajem zagrożenia już zajmowaliśmy się w pierwszym submodule. Atakujący stara się wykorzystać brak odpowiednich filtrów w aplikacji i próbuje wprowadzić wadliwy kod.

Intencja może być różna. Czasem chodzi o próbę wykradzenia danych użytkowników, np. poprzez popsucie warunku pobierającego dane za pomocą find. Innym razem celem jest po prostu zaburzenie działania strony, jak to miało miejsce w naszym przykładzie z komentarzami, gdzie atakujący po prostu zakrywał całą stronę swoim komunikatem. Mimo tego, że jest to zagrożenie znane od bardzo dawna, wciąż niektóre strony są na niego podatne.

Jak sobie z tym radzić?

Odpowiedź jest już znana. Należy walidować wszystkie dane nie tylko po stronie klienta, ale również serwera. Musimy sprawdzać ich zgodność z tym, czym powinny być. Powinniśmy je również odpowiednio parsować.

Broken Authentication

Chodzi tutaj o wadliwie działający system autoryzacji lub informacje o zalogowanym użytkowniku są zapisane w taki sposób, że można je modyfikować i podmieniać. Innym błędem może być pokazywanie linku do logowania dla administratorów na widoku dla wszystkich i brak ochrony przed atakiem typu brute-force. W takiej sytuacji haker przy użyciu jakiegoś narzędzia stara się tak długo testować multum kombinacji, aż w końcu trafi na dobry login i hasło. Dobry system autoryzacji powinien zablokować taką możliwość już po kilku pierwszych nieudanych próbach.

Jak sobie z tym radzić?

Najlepiej... w ogóle nie pisać własnego systemu autoryzacji. Zwyczajnie jest zbyt dużo aspektów, które musimy wziąć pod uwagę. Zamiast tego lepiej skorzystać z gotowych i uznanych rozwiązań, np. Auth0 i Passport.

Sensitive Data Exposure

To temat, który już poruszaliśmy. Nie powinniśmy pozostawiać na widoku danych, które mogłyby zostać wykorzystywane przez hakera do przeprowadzenia ataku. Przykładem niech będzie wspomniane w pierwszym submodule przechowywanie kluczowych informacji na publicznym repozytorium. Nie powinniśmy również umieszczać wrażliwych danych w źle zabezpieczonej bazie. Chodzi tutaj o imiona, nazwiska, numery kart kredytowych itd.

Jak sobie z tym radzić?

Co do ustawień witryny (np. haseł do bazy), staraj się nie przetrzymywać takich danych w publicznych plikach. Jeśli musimy z nich korzystać, to wyjściem może być użycie zmiennych środowiskowych.

Jeśli chodzi o dane użytkowników, najlepiej korzystać z jakiejś popularnej i cenionej usługi, która zapewni im bezpieczeństwo. Gdy koniecznie musimy zająć się tym sami, szyfrujmy takie dane.

XML External Entities (XXE)

Akurat tym punktem nie musimy się przejmować. Nie używaliśmy dotychczas w naszej pracy plików XML, bo w przypadku większej ilości danych, korzystamy ze znacznie łatwiejszego w parsowaniu formatu JSON. Nie będziemy więc poruszać tego tematu.

Broken Access Control

Chodzi tutaj o niepoprawne "ukrywanie" niektórych części witryny. Nie powinniśmy np. pozostawiać podstron dla adminów w taki sposób, żeby zabezpieczała je tylko nieznajomość adresu przez atakującego. Taka strona powinna sprawdzać przy requeście, czy prosi o nią rzeczywiście administrator. Tego typu błędy najczęściej są dziełem przypadku i nieuwagi.

Wyobraź sobie, że u Ciebie rolę bufora pełni np. funkcja isAdmin(), która sprawdza, czy użytkownik jest zalogowany. Jeśli nie, to przerywa aktualnie wykonywany kod i zwraca odpowiedni błąd. Jeśli jednak jest zalogowany, to pozwala mu działać dalej. Idea jest bardzo dobra, ale w sytuacji, gdy taką funkcję odpalalibyśmy każdorazowo w endpointach dla admina, to możliwe, że podczas pisania kodu, w którymś miejscu o tym zapomnimy. W taki sposób dalibyśmy atakującemu dostęp do jednej z wrażliwych podstron.

Jak sobie z tym radzić?

Przede wszystkim powinniśmy używać systemu autoryzacji, który nie ma luk bezpieczeństwa i potrafi dobrze ukrywać wrażliwe podstrony zależnie od posiadanych uprawnień. Niemniej jednak, oprócz tego sami musimy się pilnować, aby ustawić poprawne uprawnienia dla konkretnych części strony czy aplikacji. Nawet najlepsze narzędzie musi wiedzieć, co właściwie ma chronić i w jaki sposób.

Security Misconfigurations

Mowa tu o wszelkich błędach w konfiguracji serwera czy aplikacji. Możemy np. błędnie skorzystać z middleware express.static i serwer będzie udostępniał również pliki z katalogu głównego. Tym samym atakujący mógłby zajrzeć do server.js i uzyskać np. dostęp do hasła bazy danych. Nawet jeśli byłoby ono schowane w zmiennej konfiguracyjnej, to przynajmniej mógłby poznać strukturę aplikacji i przygotować bardziej zindywidualizowany atak.

Innym błędem podobnego typu mogłoby być np. ustawienie publicznej widoczności repo produkcyjnej aplikacji. Częstą pomyłką jest też korzystanie z domyślnego hasła dla gotowych usług. Gdy używamy np. systemów CMS do postawienia bloga, zdarza się, że korzystają one z prostego hasła w stylu admin1. Brak jego zmiany (sugerowanej zazwyczaj przez autorów), może dać atakującemu naprawdę łatwy dostęp do naszej strony.

Jak sobie z tym radzić?

Przede wszystkim nie traktować etapu konfiguracji usług czy aplikacji jako zła koniecznego. Często bowiem to błędy na tym etapie mogą stworzyć furtkę do zaatakowania naszej aplikacji. Zawsze podchodź ze starannością do początkowych etapów swojej pracy. Nawet zwykłe postawienie serwera za pomocą Expressu nie powinno być traktowane przez nas jak szablonowe zadanie, bo przy rutynie najłatwiej o błędy.

Cross Site Scripting (XSS)

Wyobraź sobie następujący scenariusz. Włączasz przeglądarkę, a ta z automatu próbuje odpalić ten request na Twojej stronie:

POST

{
  "email": "tester@example.com",
  "password": "123",
  "role": "admin"
}

Oczywiście nie wiemy o tym procederze. Załóż, że przeglądarka została wcześniej zhakowana.

Póki nie jesteśmy zalogowani, nie musimy się niczego obawiać. Request zostanie odrzucony przez nasz serwer. Powiedzmy jednak, że po godzinie pracy, postanawiasz zalogować się na swoją stronę, na której masz uprawnienia administratora. Przeglądarka szybko może to wykryć i powtórzyć request. Jak tym razem zareaguje serwer? Skoro request jest odpalany z Twojego komputera, z tej samej przeglądarki, to posiada aktywną sesję. Serwer rozpozna więc Ciebie i doda do strony nowego użytkownika... o uprawnieniach admina. Następnie przeglądarka może poinformować atakującego o sukcesie, a ten zaloguje się na Twoją stronę za pomocą nowego konta. To, co następnie zrobi ze swoimi nowymi uprawnieniami, zależy już tylko od niego.

Analogicznie mogłoby to wyglądać w przypadku witryny banku. Wyobraź sobie, że to bardzo prosta strona. Gdy chcemy przelać pieniądze na czyjeś konto, po prostu wykonujemy odpowiedni request do serwera (oczywiście przy użyciu normalnego formularza na stronie). Ponownie, gdyby nasza przeglądarka została zhakowana (np. przy użyciu pluginu, który by ją infekował), atakujący mógłby poczekać na moment zalogowania do banku i automatycznie odpaliłby request, który sugerowałby bankowi, że trzeba przelać jakieś pieniądze na jego konto. Oczywiście to bardzo abstrakcyjny przykład, bowiem banki mają jeszcze chociażby system autoryzacji SMS do potwierdzania przelewu. Rozumiesz jednak, jak dużym niebezpieczeństwem jest ta technika.

Ataki tego typu są przeważnie ciężkie do przygotowania, sprawca musi bowiem dokładnie wiedzieć, w jaki sposób należy korzystać z witryny. Dlatego też najbardziej narażone są tutaj popularne CMS-y (np. Wordpress), gdyż ich bazowy kod jest zawsze taki sam. Atakującemu łatwiej przygotować wtedy dobrą strategię, a odpowiedni skrypt może wykorzystywać wiele razy. W przypadku autorskich aplikacji jest to już znacznie trudniejsze, ale wciąż możliwe. Wymaga po prostu bardziej spersonalizowanego podejścia, bo atakujący musi poznać witrynę i odpowiednio przygotować skrypt.

Jak sobie z tym radzić?

Skrypty korzystające z tej techniki mogą być dostarczane w bardzo różny sposób, np. w postaci wtyczki do CMS-a, lub zainfekowanego pluginu do przeglądarki, który będzie wykorzystywał ją jako narzędzie. Może to być również... zainfekowana zależna paczka. Jak więc sobie z tym radzić? Musimy być po prostu uważni i nie korzystać z podejrzanych źródeł. To rady, które wiążą się trochę z tym, o czym już mówiliśmy w przypadku dobrych praktyk wybierania zależności. Warto też w przypadku krytycznych funkcjonalności sprawdzać skąd pochodzi request. Oczywiście atakujący może podrobić ten adres, ale to wymaga od niego kolejnej dawki wiedzy. Powinniśmy utrudniać każdy atak jak tylko to możliwe.

Insecure Deserialization

To trochę trudniejszy temat, bowiem nie mówiliśmy jeszcze o idei serializacji czy deserializacji danych, chociaż tak naprawdę już z niej korzystaliśmy. O co więc chodzi?

Serializacja to w skrócie proces konwertowania obiektu do formatu, który może być zapisany na dysku, czy w pamięci, jako zwykły ciąg znaków. Chodzi o przekształcenie do zapisu binarnego, ale też np. do trochę bardziej czytelnego formatu tekstowego (jak JSON). Idea nie jest nam obca, bo przecież już nie raz byliśmy zmuszani odpowiednio konwertować dane z obiektu JS-owego do formatu JSON, kiedy np. wysyłaliśmy requesty do API, używaliśmy funkcji JSON.stringify. Co ciekawe, serializować możemy nawet obiekty z metodami, oczywiście o ile mamy potem odpowiednie narzędzie do ich poprawnego zdeserializowania.

Deserializacja to, jak zapewne się domyślasz, proces odwrotny do serializacji. Tym też już się zajmowaliśmy – gdy otrzymywaliśmy dane z API, to po ich odebraniu, konwertowaliśmy je do obiektu JSowego, tak aby dało się z niego korzystać w naszej aplikacji. Był to właśnie proces deserializacji danych.

Kiedy chcemy zapisać obiekt poza aplikacją to go serializujemy, a w sytuacji, gdy chcemy wykorzystać tekst jako obiekt w aplikacji, to go deserializujemy. Oczywiście o ile to możliwe, bo np. tekstu w stylu abc nie będziemy w stanie skonwertować do obiektu JS-owego.

Jakie mamy zagrożenia z tym związane?

Jeden przykład pokazaliśmy już w poprzednim submodule. Jeśli zakładasz, że dane pomiędzy klientem a serwerem będą przesyłane poprzez requesty AJAX, to haker bardzo łatwo może takie dane podmienić. Wystarczy, że sam wyśle odpowiedni request i podszyje się pod klienta. Tym samym niczego nieświadomy serwer zdeserializuje takie fałszywe dane, a następnie z nich skorzysta.

Inny przykład. Niektórzy developerzy przechowują dane o użytkowniku w ciasteczkach. Cookies nie przyjmują obiektów jako wartości, dlatego takie informacje są oczywiście serializowane do binarnego ciągu znaków. Może wydawać się, że to dość bezpieczny zabieg i hakerzy raczej nie będą w stanie czegoś tutaj zmodyfikować. Nic bardziej mylnego. Haker może zdeserializować takie dane, podmienić ich zawartość, potem znowu zserializować i przypisać jako nową wartość ciasteczka.

Jak sobie z tym radzić?

Kilka rad już znasz. Musimy sprawdzać, co dokładnie otrzymujemy z zewnątrz. Nie powinniśmy też działać w taki sposób na istotnych danych. Przykładowo, nie możemy dopuścić do tego, aby to w requeście była przesyłana informacja o naszych uprawnieniach. Lepiej sprawdzić na serwerze, czy aby jesteśmy aktualnie poprawnie zalogowani. Tak samo, jak nie powinniśmy przetrzymywać informacji o loginie użytkownika w ciasteczku. Dodatkowo możemy też korzystać z sum kontrolnych, które pozwolą upewnić się nam, czy dane nie zostały przypadkiem po drodze sfałszowane.

Using Components with Known Vulnerabilities

To zagrożenie, o którym już mówiliśmy. Zdarza się, że developerzy nie zwracają uwagi na wiarygodność paczek zewnętrznych, z których korzystają. Jeśli jej opis i zastosowanie ich zadowala, po prostu ją pobierają, nie zważając na potencjalne luki w bezpieczeństwie. Paczki mogą być też zwyczajnie źle napisane, przez co mimo idealnego kodu samej aplikacji, haker znajdzie do niej dostęp poprzez wadliwy pakiet zależny.

Jak sobie z tym radzić?

Należy wybierać tylko znane i wciąż wspierane paczki, w których nie stwierdzono jeszcze żadnych uchybień. Dodatkowo warto korzystać z narzędzi, za pomocą których co jakiś czas przejrzymy wszystkie zależności pod względem luk bezpieczeństwa. To, że paczka wydawała się godna zaufania w momencie jej pobrania, nie znaczy, że wciąż taka jest. Hakerzy co rusz wynajdują kolejne luki, a autorzy paczek muszą je na bieżąco naprawiać.

Insufficient Logging and Monitoring

W tym punkcie chodzi bardziej o monitorowanie zagrożeń niż bezpośrednie im zapobieganie. Aby dobrze planować ewentualne zmiany w aplikacji związane z bezpieczeństwem, musimy wiedzieć jakim atakom już podlegamy.

Wyobraź sobie, że haker starał się wielokrotnie logować na konto admina. Wygląda na to, że używa jakiegoś oprogramowania, gdyż jego próby przekraczają liczbę kilku tysięcy. Jeśli zdajemy sobie z tego sprawę, możemy zareagować i np. wprowadzić system blokowania adresu IP po trzech wadliwych próbach. Wymusiłoby to na atakującym ciągłe zmienianie swojego IP i znacznie utrudniłoby jego niecny proceder. Możemy też wprowadzić dodatkową ochronę – jeśli nie udało Ci się zalogować trzy razy, to potwierdź swoją tożsamość za pomocą e-maila. Bez wiedzy, że coś się dzieje, ciężko byłoby nam zareagować, tymczasem haker wykonałby kolejne 5000 prób, przy czym ta ostatnia mogłaby okazać się już udaną.

Druga sprawa to monitorowanie udanego ataku. Wyobraź sobie, że haker zalogował się na nasze konto, a my nic o tym nie wiemy. W takiej sytuacji atakujący może na spokojnie korzystać z naszych uprawnień, edytować dane, usuwać użytkowników, a nawet przeglądać ich wrażliwe informacje. Gdyby jednak na naszej stronie istniał jakiś system, który informowałby np. o zalogowaniu do aplikacji z nowego adresu IP, to od razu moglibyśmy reagować, wymusić zakończenie sesji i szybko zmienić hasło albo nawet zablokować działanie całej witryny, aby mieć czas na naprawę luki w bezpieczeństwie. Z takiego pomysłu korzysta np. poczta Gmail. Gdy wykrywa zalogowanie z innego urządzenia lub adresu IP, od razu nas o tym informuje i pozwala zareagować.

Jak sobie z tym radzić?

Powinniśmy zastosować w naszej aplikacji takie mechanizmy, które będą w jakiś sposób informowały nas o udanych i nieudanych próbach ataków. Forma jest dowolna, system może powiadamiać nas za pomocą e-maili, czy nawet SMS-ów, ważne jednak, abyśmy dzięki niemu zdawali sobie sprawę z potencjalnego zagrożenia. Możemy np. informować administratora o podejrzanym logowaniu, gdy aktualny adres IP użytkownika jest inny niż ten zapisany w bazie danych.

Podsumowanie

Zapewne udało Ci się zauważyć, że gdybyśmy zastosowali dobre praktyki z pierwszego submodułu, to większość z tych zagrożeń nie byłaby nam straszna. Niemniej jednak dobrze przeanalizować je krok po kroku, aby zdać sobie sprawę, przeciwko czemu tak naprawdę się bronimy.

Prawdopodobnie czujesz, że bezpieczeństwo aplikacji to skomplikowana sprawa, ale jeśli będziesz trzymać się dobrych praktyk i postarasz się pamiętać o obronie przed znanymi zagrożeniami, to pisanie bezpiecznego kodu może szybko wejść Ci w nawyk. Dodajmy – bardzo pozytywny nawyk :)

Problemem w zabezpieczaniu aplikacji jest też łatwość w zrobieniu błędu, czy przeoczeniu jakiejś luki. Na to receptą jest po prostu poważne podejście do tematu. Nie możemy traktować bezpieczeństwa aplikacji jako przykrego obowiązku. Bierz to raczej jako integralną część budowy oprogramowania. Dobra aplikacja to też bezpieczna aplikacja.

31.3. Autoryzacja aplikacji przy użyciu OAuth

Dotychczas budowaliśmy aplikacje, które nie wymagały od użytkowników rejestracji ani logowania. Często jednak potrzebujemy, aby ten dostęp był trochę bardziej ograniczony albo przynajmniej różnorodny, np. kiedy chcemy wprowadzić możliwość edycji strony, czy też specjalne sekcje otwarte wyłącznie dla użytkowników premium. Takie opcje powinny być dostępne tylko dla osób z odpowiednimi uprawnieniami. Ponadto, bez kont użytkowników i modułu logowania nie bylibyśmy w stanie ustalić, kto nas tak naprawdę odwiedza.

Naturalnie idea autoryzacji to coś więcej – wiedząc, kim jest użytkownik, jesteśmy w stanie przypisywać do niego różne informacje. Prowadząc serwis wideo, moglibyśmy zapisywać obejrzane przez niego filmy, wystawione oceny, a także udostępniać spersonalizowane funkcjonalności, takie jak np. lista rekomendowanych filmów oraz strona główna, która proponowałaby wideo tylko z tych kanałów, które użytkownik polubił. Możliwości są ogromne, dlatego też właściciele witryn tak chętnie z nich korzystają.

Część stron traktuje funkcjonalność autoryzacji jako dodatek i mogłyby istnieć również bez niej. Są to np. serwisy informacyjne, które często pozwalają na przeglądanie treści nawet bez zalogowania. Istnieją też takie witryny, które bez modułu autoryzacji zwyczajnie nie mogłyby działać, jak np. Facebook czy Twitter. Każda z ich funkcjonalności opera się na tym, że użytkownik nie jest anonimowy. Zamiast tego ma swoje imię, nazwisko, awatar i własny profil, z którego może komunikować się z innymi użytkownikami. To pozwala m.in. na budowanie spersonalizowanego "walla" z postami na stronie głównej.

Niezależnie, czy będziemy budować więcej stron jednego, czy drugiego typu, nie sposób negować faktu, iż autoryzacja strony to jedna z podstawowych funkcjonalności każdej nowoczesnej witryny. Po prostu trzeba ją poznać i oczywiście umieć zaimplementować.

Własny system autoryzacji

Od razu pojawia się pierwsze pytanie – czy jesteśmy w stanie napisać system autoryzacji sami? Odpowiedź brzmi: tak. Wbrew pozorom nie jest to aż tak trudne, jak mogłoby się wydawać. Tak naprawdę wystarczą dwa formularze – do rejestracji i logowania. Pierwszy powinien dodawać nowego użytkownika do bazy danych, a drugi sprawdzać, czy istnieje tam już ktoś o podanym loginie i haśle. Jeśli nie, system powinien informować użytkownika o wpisaniu złych danych, a jeśli tak, to serwer mógłby rozpoczynać sesję. Następnie wystarczyłoby sprawdzić, czy ta jest aktywna, aby ustalać, czy użytkownik ma dostęp do zasobów, które próbuje załadować, np. czy może połączyć się z odpowiednim requestem. Proste?

Tutaj nasuwa się drugie pytanie, a mianowicie czy możemy sami napisać bezpieczny system autoryzacji? W większości przypadków, o ile nie jesteś specem w dziedzinie bezpieczeństwa IT, odpowiedź brzmi: nie. Już wcześniej wspominaliśmy, że kiedy tylko to możliwe, staraj się korzystać z gotowych rozwiązań. Podczas implementacji systemu autoryzacji możemy zrobić tak wiele błędów i pozostawić taki ogrom luk, że jeśli nie jesteśmy w stu procentach pewni, że sobie z tym poradzimy, lepiej w ogóle się za ten temat nie zabierać. Stworzenie dobrego i bezpiecznego systemu autoryzacji jest zwyczajnie bardzo trudne.

Nawet jeśli udałoby się nam stworzyć względnie dobry system, to bardzo możliwe, że w krótkim czasie stałby się on podatny na ataki. Na przykład kilkanaście lat temu standardem do kodowania hasła (aby w bazie nie przechowywać zwykłego tekstu) było szyfrowanie MD5. Wciąż możesz trafić na tutoriale sprzed paru lat, które z niego korzystają i zachwalają jego bezpieczeństwo. Tymczasem takie rozwiązanie jest już całkowicie skompromitowane. Tak naprawdę nie potrzeba żadnych specjalnych narzędzi, aby je złamać. Wystarczy, że wpiszesz zakodowane hasło w wyszukiwarkę i prawdopodobnie od razu otrzymasz wynik z rozszyfrowaną treścią. Wniosek jest taki, że nawet gdybyśmy stworzyli system autoryzacji, który byłby w danym momencie bezpieczny, to i tak musielibyśmy co jakiś czas do niego wracać, aby poprawiać, czy wręcz zmienić szyfrowanie na nowocześniejsze i silniejsze.

Podsumowując, własny system autoryzacji wiąże się z potrzebą modyfikacji w przyszłości. To dość kłopotliwe, bo wymaga od nas czasu i wymusza konieczność bycia na bieżąco z nowinkami w branży. W przypadku gotowych rozwiązań wciąż wspieranych i nowelizowanych wystarczy zaktualizować wersję paczki.

Jeszcze inny aspekt to samo przechowywanie haseł – trzeba je odpowiednio kodować i ukrywać. W przypadku własnego systemu musimy martwić się o to, aby dane były bezpieczne. Gdy dojdzie do włamania, mogą one zostać wykradzione i odkodowane, co narazi naszych użytkowników na ogromne niebezpieczeństwo. W końcu często korzystamy z takim samych haseł na wielu stronach. Dodatkowo bardzo negatywnie wpłynęłoby to na reputację naszego biznesu. W przypadku zewnętrznego systemu hasła nie są przechowywane przez nas, a chronią je twórcy tych rozwiązań, jak np. Google czy Facebook.

Podsumowując, jeśli tylko masz taką możliwość, korzystaj z gotowego systemu autoryzacji.

Czym jest protokół OAuth 2.0

Wiemy już, że warto skorzystać z jakiegoś gotowego rozwiązania, ale które będzie najlepsze? Obecnie na rynku jednym z najpopularniejszych wyborów jest OAuth 2.0. To właśnie z niego będziemy korzystać w następnym submodule.

OAuth 2.0 oferuje system autoryzacji umożliwiający logowanie za pomocą zewnętrznych serwisów, takich jak np. Google czy Facebook. Lista wspieranych witryn jest jeszcze dłuższa, możemy wykorzystywać również LinkedIn, Twitter czy GitHub.

Pomysł jest tutaj bardzo prosty. W naszej aplikacji umieszczamy buttony np. o treści "Login with Google", czy też "Login with FB". Ich kliknięcie przenosi nas do odpowiedniego zewnętrznego serwisu, gdzie następuje logowanie. Co istotne, zauważ, że ten proces odbywa się poza naszą aplikacją i w ogóle nie ingerujemy w to, jak dokładnie wygląda ten mechanizm. Oczywiście, jeśli wykorzystujemy np. Facebook jako pośrednika, to użytkownik musi zalogować się na swoje konto w tym serwisie. Następnie, po wszystkim zostaje on przeniesiony z powrotem do naszej aplikacji, przy czym pośrednik informuje nas, czy logowanie się udało, czy nie, a my decydujemy, co z tą wiedzą zrobimy. Jeśli proces przebiegł pozytywnie, możemy rozpocząć sesję użytkownika, a w przypadku niepowodzenia pokazujemy np. jakiś komunikat z błędem.

Zapewne udało Cię się spotkać z taką metodą logowania, gdyż OAuth jest bardzo popularny. Korzystają z niego twórcy mniejszych stron, którzy nie chcą brać na swoje braki tworzenia własnego systemu autoryzacji, ale też giganci z różnych branż. Bardzo często widujemy to rozwiązanie również w aplikacjach mobilnych.

Przykłady wykorzystania tej technologii możesz znaleźć na witrynach imdb.com oraz ebay.com.

image

Dlaczego warto z niego skorzystać?

Jakie zalety ma wykorzystanie OAuth 2.0?

Po pierwsze, przyspiesza pracę. Jak mówiliśmy już wcześniej, budowa własnego systemu autoryzacji to trudna i czasochłonna sprawa. Korzystając z gotowego rozwiązania, oszczędzamy sporo czasu i nerwów.

Po drugie, gwarantuje nam większe bezpieczeństwo. Nie wiemy, jak dokładnie zbudowane są systemy autoryzacji Facebooka czy Google, ale możemy być pewni, że ciężko byłoby nam wykonać coś równie bezpiecznego i wyrafinowanego. Firmy te zatrudniają prawdziwych specjalistów, którzy dysponujących ogromną wiedzą i zasobami technicznymi. Nam samym ciężko byłoby zaoferować i utrzymywać system SMS do autoryzacji, a dla gigantów to dopiero początek listy możliwości.

Po trzecie, ułatwia dostęp użytkownikom. Potrzeba rejestracji nowego konta często odstrasza potencjalnych klientów. Możliwość szybkiego zalogowania za pomocą dosłownie jednego kliknięcia jest tutaj sporym atutem.

Dużym plusem jest też przechowywanie haseł użytkowników poza naszą aplikacją. Całkiem możliwe, że mimo rejestracji za pośrednictwem zewnętrznych serwisów i tak będziemy chcieli przechowywać dane o użytkowniku w naszym serwisie, np. jego komentarze lub informacje, jakie polubił filmy. Co jednak istotne, nie jesteśmy zmuszeni do przechowywania hasła, a to o nie najczęściej się boimy. Jedyne dane, które musimy magazynować u siebie, to tylko id użytkownika, wskazujące na niego w pośredniczącym serwisie. Chodzi po prostu o to, żeby po ponownym zalogowaniu np. przez Google, można było stwierdzić, jaki dokument w kolekcji users odpowiada właśnie naszemu użytkownikowi. Możemy to zrobić, porównując identyfikator otrzymany z zewnętrznego serwisu z identyfikatorami w naszej bazie.

Ostatnią zaletą jest poczucie bezpieczeństwa wszystkich stron. My nie martwimy się, że nasz system ma jakieś luki, a użytkownik nie boi się, że jego hasło zostanie ujawnione. Zaufanie do takich gigantów z branży jak FB czy Google w kwestiach bezpieczeństwa jest bowiem naturalnie wyższe niż do twórców pierwszego lepszego serwisu, czy aplikacji.

Kiedy postawić na autorski system?

Czy są jakieś wyjątki? Sytuacje, gdzie powinniśmy postawić na własny system autoryzacji?

Owszem, choć zdarza się to rzadko. Przykładem mogą być aplikacje bankowe, dla których możliwość logowania przez Google'a nie zda egzaminu, bo jest zwyczajnie... za prosta. Tego typu instytucje stawiają często na wielostopniową autoryzację, która jest bezpieczniejsza. Nie wystarczy im sam login i hasło, dodatkowo korzystają np. z kodów wysyłanych SMS-em, czy też proszą o wpisywanie tylko części słowa kluczowego. Taki system zabezpieczeń jest więc znacznie bardziej wyrafinowany.

Zasada działania

Podstawowa koncepcja jest dość prosta. Mamy tu właściwie trzy etapy.

Etap 1 – aplikacja prosi o autoryzację

Użytkownik zamiast formularza do rejestracji czy logowania otrzymuje tylko button, np. o treści "Sign in with Google". Po kliknięciu na niego aplikacja przekierowuje go do zewnętrznego serwisu.

Etap 2 – autoryzacja

Tam następuje logowanie przy użyciu konta adekwatnej witryny. Po próbie zalogowania użytkownik jest kierowany z powrotem do aplikacji.

Etap 3 – aplikacja otrzymuje wynik autoryzacji

Przy okazji serwis pośredniczący informuje też o sukcesie lub porażce autoryzacji. Z tą wiedzą nasza aplikacja może np. powitać użytkownika i rozpocząć dla niego nową sesję.

Koncepcję tę podsumowuje poniższa grafika:

image

To oczywiście bardzo uproszczony zapis. Jak pewnie się domyślasz, "pod maską" naszej aplikacji będzie działo się trochę więcej.

Krok po kroku

Teraz opowiemy już o całej idei dokładniej.

Etap 1

Wiemy, że wszystko zaczyna się od wybrania odpowiedniego guzika, ale co dzieje się dalej? Najczęściej przygotowujemy specjalny endpoint przeznaczony do logowania, pod który trafia użytkownik po kliknięciu na button. Przyjęło się, że nazywamy go w formacie auth/pośrednik, czyli jeśli ma odpowiadać za logowanie z wykorzystaniem Google'a, będzie to adres auth/google.

image

Pod samym endpointem, kryje się już kod odpowiedzialny za połączenie z pośrednikiem, a więc serwerem Google'a, Facebooka, czy np. LinkedIn. Tę komunikację zapewni nam właśnie OAuth. Już teraz warto dodać, że najczęściej korzystamy też z pomocy dodatkowej paczki o nazwie Passport, która trochę ten proces upraszcza.

Wraz z samym połączeniem musimy wysłać do pośrednika kilka informacji. Zależnie od tego, z kim dokładnie się łączymy, mogą być różne. Najczęściej jednak są to przynajmniej trzy – klucz użytkownika, sekretny ciąg znaków potwierdzający jego tożsamość oraz adres callback do naszej aplikacji.

Pierwsze dwie informacje biorą się ze sposobu, w jaki pośrednicy udostępniają swoje "zasoby". Najczęściej chcą oni, aby przed skorzystaniem z ich usług założyć odpowiednie konto developerskie w ich serwisie. Jaki jest tego cel? Dzięki temu mogą wymagać od programistów komunikowania się przy użyciu danych identyfikujących (jak właśnie klucz użytkownika czy jego "sekretny" kod). To z kolei pozwala na dokładne śledzenie, kto korzysta z ich usług oraz w jaki sposób. Daje to chociażby możliwość analizy zachowań czy też zainteresowań użytkowników.

Co do ostatniej wymaganej informacji (adres callback), zapewne już domyślasz się, do czego jest potrzebna. Pośrednik po udanym bądź nieudanym zalogowaniu musi przekierować użytkownika z powrotem do naszej witryny. Adres callback powie mu, gdzie dokładnie należy go odesłać. Naturalnie nasz serwer (aplikacja) musi umieć ten adres obsługiwać, więc jeśli powiemy pośrednikowi, że po wszystkim ma odesłać użytkownika pod auth/google/callback (nazwa może być oczywiście inna), to musimy mieć taki endpoint przygotowany.

Oprócz tego, przy połączeniu możemy wskazać również, co dokładnie będzie nam potrzebne – tylko adres e-mail, czy może jeszcze awatar, lub inna kombinacja. Dlaczego nie poprosić od razu o wszystko? Z prostego powodu. Większość pośredników, przed próbą logowania, informuje użytkownika, jaki dostęp chce uzyskać dany serwis. Tym samym, jeśli użytkownik zobaczy, że prosimy o jakaś absurdalną ilość danych albo zwyczajnie o coś, czego na logikę nie potrzebujemy, może go to odstraszyć.

image

Na grafikach przedstawiamy schemat połączenia z wykorzystaniem Google, niemniej jednak zasada działania dla pozostałych serwisów jest praktycznie identyczna.

Etap 2

Teraz władzę przejmuje już pośrednik. To on pokazuje użytkownikowi formularz logowania i przeprowadza go przez proces autoryzacji. Tak naprawdę ta część całego procesu interesuje nas najmniej, bo zwyczajnie nie mamy na nią wpływu. Zresztą to, w jaki sposób pośrednik sprawdzi tożsamość użytkownika, nie jest dla nas ważne, o ile zrobi to bezpiecznie. Pod tym względem gigantom branży raczej możemy zaufać.

image

Po autoryzacji pośrednik przekieruje użytkownika z powrotem do aplikacji. Oczywiście nie będzie zgadywać adresu, tylko użyje tego, który wskazaliśmy jako callback. Najważniejsze są jednak dane dostarczane wraz z tym przekierowaniem. Pośrednik informuje nas o sukcesie lub porażce autoryzacji oraz przekazuje nam informacje o użytkowniku (oczywiście tylko te, o które prosiliśmy i tylko wtedy, kiedy faktycznie udało mu się zalogować).

image

Etap 3

Aby przyjąć te dane, aplikacja musi posiadać odpowiedni endpoint. Tutaj najczęściej renderujemy podstronę warunkowo, by po sukcesie użytkownik zobaczył np. komunikat "Udało się!", a po porażce "Brak dostępu!". Przy okazji aplikacja najczęściej rozpoczyna też sesję użytkownika (w końcu po to się logujemy), może także dodać go do swojej bazy danych, dzięki czemu jest w stanie przypisywać jego późniejsze działania do odpowiedniej osoby.

image

Podsumowanie

Oczywiście, to jak dokładnie nazwiemy nasze endpointy, z jakiego providera (pośrednika) skorzystamy, czy też co zrobimy z informacją od niego otrzymaną, zależy już tylko od nas. Niemniej jednak w przypadku sukcesu, zawsze będzie to wyglądało mniej więcej tak, jak na grafice poniżej.

image

Możesz potraktować to jako przewodnik lub ściągę do wykorzystania w dalszej części modułu.

Jak podoba Ci się pomysł użycia OAuth 2.0? Przyznaj, że idea dodania do strony tak zaawansowanego mechanizmu logowania wydaje się bardzo kusząca, a w dodatku... będzie to stosunkowo proste, choć wymaga trochę pracy. Przekonasz się jednak, że ilość kodu nie będzie przytłaczająca, a efekt jest na pewno wart zachodu.

31.4. OAuth i Passport w praktyce

Najlepszym sposobem na dogłębne poznanie tematu jest uczenie się w praktyce, dlatego też działanie OAuth 2.0 omówimy w czasie procesu implementacji.

Co zbudujemy?

Nic wielkiego. W założeniu będzie to prosta witryna z zaledwie trzema elementami: stroną główną, podstroną sukcesu ("Zalogowano!") oraz porażki ("Brak uprawnień!").

Strona główna będzie miała button, którego kliknięcie przeniesie nas do pośrednika, u którego nastąpi zalogowanie. My użyjemy Google'a, ale równie dobrze moglibyśmy wykorzystać innych providerów.

image

Podstrona sukcesu ma za zadanie zakomunikować użytkownikowi, że autoryzacja się powiodła.

image

Podstrona porażki będzie nam potrzebna w razie nieudanego zalogowania. Poinformuje użytkownika, że nie ma on wystarczających uprawnień do przebywania na naszej stronie.

image

Witryna ma więc pozwalać tylko na logowanie przy użyciu mechanizmu autoryzacji Google, a następnie pokazywać informację, że się to udało albo nie. Musisz przyznać, że brzmi to jak książkowy przykład wykorzystania OAuth, no cóż... tak właśnie jest. Dzięki takiej prostocie powinien on być łatwy do przyswojenia.

Zaczynamy!

Nie musisz startować całkiem od zera. Przygotowaliśmy dla Ciebie bazę, na której będziemy się opierać. Możesz ją pobrać pod tym linkiem.

Zacznij od analizy pliku server.js.

const express = require('express');
const cors = require('cors');
const path = require('path');
const hbs = require('express-handlebars');

const app = express();

app.engine('hbs', hbs({ extname: 'hbs', layoutsDir: './layouts', defaultLayout: 'main' }));
app.set('view engine', '.hbs');

app.use(cors());
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.use(express.static(path.join(__dirname, '/public')));

app.get('/', (req, res) => {
  res.render('index');
});

app.get('/user/logged', (req, res) => {
  res.render('logged');
});

app.get('/user/no-permission', (req, res) => {
  res.render('noPermission');
});

app.use('/', (req, res) => {
  res.status(404).render('notFound');
});

app.listen('8000', () => {
  console.log('Server is running on port: 8000');
});

Jak widzisz, nie ma tu niczego specjalnie skomplikowanego. Tworzymy serwer, dodajemy obsługę szablonów Handlebars i przygotowujemy podstawowe endpointy. Wszystko to już robiliśmy wcześniej. Jedyne co może Cię dziwić, to pojawienie się systemu szablonów, ale akurat tutaj ma to sens. Skoro nie korzystamy z Reacta, nie możemy użyć jego mechanizmu routingu. Tym samym pozostaje nam tylko routing po stronie backendu i pokazywanie różnych plików zależnie od sytuacji. Korzystanie z szablonów znacznie nam ten proces ułatwia.

Pobierz teraz potrzebne paczki zależne (yarn install). Możesz też odpalić serwer yarn start i sprawdzić jak wygląda każdy z endpointów. Zauważ, że / pokazuje nam właśnie stronę główną, /user/logged podstronę sukcesu, a /user/no-permission porażki.

Mamy więc podwaliny serwera. Naszym zadaniem będzie teraz ożywienie przycisku "Login with Google", tak aby faktycznie pozwalał na autoryzację i w rezultacie przekierowywał użytkownika do odpowiedniej podstrony – /user/logged albo /user/no-permission.

Pobieramy potrzebne paczki

Żeby wykonać to zadanie, będziemy musieli skorzystać z pomocy zewnętrznych paczek. Oczywiście możemy użyć auth0 dla Node'a, niemniej jednak lepszym pomysłem będzie Passport, który także służy do autoryzacji, ale oferuje dodatkowo przyjaźniejszy interfejs.

Zainstaluj więc teraz tę paczkę:

yarn add passport@0.4.0

Dodatkowo musimy pobrać jeszcze dwie – passport-google-oauth20 oraz express-session.

Pierwsza pozwoli na wykorzystanie mechanizmu logowania Google, a druga odpowiada za funkcjonalność inicjacji i obsługi sesji użytkownika.

yarn add passport-google-oauth20@2.0.0 express-session@1.17.0

Jedna z wielu

Dlaczego Passport nie wspiera autoryzacji Google od razu i potrzebne są dodatkowe paczki?

Powód jest prosty. Na rynku istnieje mnóstwo serwisów, udostępniających swoje systemy logowania dla stron trzecich, wystarczy wejść na http://www.passportjs.org/ i odwiedzić zakładkę "Strategies". Znajduje się tam lista wszystkich możliwych systemów autoryzacji, które potrafią współpracować z Passportem. Zauważ, że jest ich ponad 500.

image

Każdy pośrednik może mieć system, który działa trochę inaczej niż pozostałe. Gdyby Passport chciał udostępniać od razu wszystkie, wielkość jego paczki byłaby przeogromna. Zamiast tego zawiera tylko główną logikę oraz obsługę OAuth 2.0, a żeby skorzystać z wybranego pośrednika, wystarczy pobrać do tego dodatkowy pakiet.

Google API Console

Wspomnieliśmy już wcześniej, że większość pośredników wymaga odpowiedniego konta do korzystania z ich systemów autoryzacji. Najczęściej dostajemy wtedy jakiś identyfikator, który musimy następnie wykorzystywać przy komunikacji z nimi. Dzięki temu pośrednik jest w stanie zawsze ustalić, kto tak naprawdę inicjuje połączenie, bo nie są to anonimowe przekierowania.

Google ma dokładnie taką samą taktykę. Udostępnia swój mechanizm logowania za darmo, ale żeby z niego korzystać, musimy posiadać konto w serwisie Google APIs (możesz użyć konta Gmail). Dodatkowo należy wygenerować dla każdej aplikacji, w której będziemy go używać, indywidualny klucz. Zauważ więc, że Google nie tylko będzie wiedziało, że inicjatorem jest np. johndoe@example.com, ale również to, która z jego aplikacji prosi o autoryzację.

Wejdź więc teraz do serwisu Google API Console i zacznij od utworzenia nowego projektu.

image

Nazwa może być dowolna, podobnie jak lokalizacja.

Ekran logowania

Następnie przejdź do zakładki Ekran akceptacji OAuth 2.0. Google przy próbie logowania pokazuje użytkownikowi informacje o tym, kto prosi o autoryzację oraz do jakich danych potrzebuje dostępu. Często pozwala też na przeczytanie spersonalizowanej polityki prywatności dla danego serwisu. To, co dokładnie się tam pokaże, możemy ustalić właśnie w tej zakładce.

image

Przyjrzyjmy się dokładnie temu formularzowi.

W polu Nazwa aplikacji możesz wpisać dowolny ciąg znaków. W normalnych warunkach oczywiście podalibyśmy tytuł naszego serwisu, ale teraz wystarczy np. "UserPanel.app".

Logo aplikacji to fakultatywna opcja, z której twórcy stron czasami korzystają. Jej ustawienie powodowałoby pokazywanie na etapie logowania nie tylko nazwy, ale też logo strony, do której użytkownik próbuje się dostać. Możesz zostawić to pole pustym.

Adres e-mail pomocy to adres, pod który będzie kierował link "Pomoc".

image

Zakresy dla interfejsów API to coś, co interesuje nas najbardziej. Domyślnie Google oferuje dostęp do informacji o e-mailu i profilu (a więc np. nazwie użytkownika). Możemy to zmienić i prosić użytkownika o więcej, np. dostęp do jego dysku na Google Drive. Pamiętaj jednak, że informacja o tym, co nasza aplikacja chce wykorzystywać, będzie pokazywana użytkownikowi przy logowaniu, a prośba o podejrzanie duży wgląd może go odstraszyć. W naszym przypadku pozostaw te zakresy bez zmian – informacje o profilu i adresie e-mail zdecydowanie wystarczą.

image

Niżej pozostaje nam jeszcze kilka pól do wypełnienia.

image

W polu Autoryzowane domeny musimy wpisać po prostu adres naszej strony. Informujemy tutaj Google, gdzie tak naprawdę dostępna jest aplikacja. Oczywiście na razie działa ona tylko pod adresem localhost, ale gdyby była hostowana gdzieś zdalnie, to wpisalibyśmy tutaj poprawną nazwę domeny, np. example.com.

Pozostałe trzy pola pozwalają na ustawienie adresów, do których będą prowadzić linki w oknie logowania. Chodzi tu między innymi o zakładkę "Warunki" czy "Prywatność", skąd możemy przekierowywać użytkownika np. pod adres /privacy-policy, gdzie opisujemy, jak wygląda nasza polityka prywatności. Możesz zostawić te pola puste.

Następnie kliknij przycisk Zapisz.

Tworzymy klucz API

Przechodzimy do istotniejszej kwestii – wygenerowania unikalnego klucza. Będzie on konieczny, by nasza aplikacja mogła łączyć się z Google. Pośrednik ten nie zezwala na anonimowe przekierowanie.

Przejdź więc do zakładki Dane logowania, a następnie kliknij na przycisk Utwórz dane logowania. Google dopyta Cię jeszcze, o jakie dane dokładnie chodzi. Wybierz Id klienta 0Auth.

image

Naszym oczom ukaże się kolejny formularz. Musimy zacząć od określenia typu, więc wybierz opcję Aplikacja internetowa. Następnie nazwij klucz API, który będziemy generować, postaraj się zawrzeć informację, o jaką aplikację chodzi. Możliwość nazewnictwa kluczy w panelu Google to duża zaleta. Wyobraź sobie, że masz np. 20 aplikacji, a więc tyle samo kluczy i chcesz zmienić dane jednego z nich. Szukanie po samej wartości byłoby dość kłopotliwe, zwłaszcza że są to zawsze mało czytelne ciągi znaków.

Pozostaje nam jeszcze kwestia ograniczeń. To najważniejsza część tego formularza. W polu Autoryzowane źródła JavaScript musisz ustalić adres aplikacji, a więc http://localhost:8000. Informujemy tutaj Google, gdzie będą przechowywane skrypty, mogące korzystać z tego klucza. Wydaje się to zbędnym ograniczeniem, niemniej jednak wiemy już, że ważną częścią tworzenia bezpiecznych aplikacji jest właśnie blokowanie nieautoryzowanego dostępu. Wyobraź sobie, że Google nie ogranicza klucza API tylko do Twojej aplikacji. Gdybyśmy wtedy udostępnili jej kod na GH, Twój klucz mógłby zostać wykorzystany przez osoby trzecie do podszywania się pod Ciebie, nawet gdyby requesty były wysyłane z całkiem innych domen.

Drugie istotne pole to Autoryzowane identyfikatory URI przekierowania. Chodzi tu po prostu o adres callback – pośrednik musi wiedzieć, gdzie ma przekierować użytkownika po udanej autoryzacji. Pojawia się jednak pytanie, dlaczego robimy to w tym miejscu? W końcu mówiliśmy, że serwer będzie informował pośrednika o callbacku również w momencie, w którym przekierowuje do niego użytkownika. To prawda, jednak znowu chodzi o bezpieczeństwo. Dzięki temu, że Google ma zapisane, jaki powinien być adres callback, może sprawdzić, czy serwer aby na pewno prosi o przekierowanie pod wcześniej założony link. Dzięki temu upewnia się, że po drodze nie doszło do jakiejś niechcianej modyfikacji kodu. Wpisz tutaj po prostu http://localhost:8000/auth/google/callback. Tego endpointu jeszcze w naszej aplikacji oczywiście nie mamy, ale niedługo go przygotujemy.

image

Po wszystkim kliknij przycisk Utwórz.

Powinniśmy zobaczyć modal z wygenerowanym clientID oraz sekretnym kodem. Możesz zapisać gdzieś te wartości, ale zawsze istnieje szansa powrotu do panelu, skąd da się je w dowolnej chwili skopiować. Twój klucz będzie już na stałe zapisany w "Danych logowania" pod nazwą, którą dla niego wcześniej wybraliśmy.

image

Podsumowanie

Trochę się napracowaliśmy, ale było warto. Udało nam się wygenerować clientID oraz sekretny klucz, który będziemy mogli wykorzystywać, aby łączyć się z systemem autoryzacji Google z poziomu naszej aplikacji. Do tego skonfigurowaliśmy odpowiednio ekran logowania, który będzie pojawiał się użytkownikowi po przekierowaniu.

Przygotowujemy serwer

Skoro mamy już odpowiednie dane, wróćmy do naszego serwera. Musimy go skonfigurować, by zainicjować działanie Passportu, a także poinformować, z jakiego pośrednika będziemy korzystać.

Zacznijmy od zaimportowania wszystkich pakietów – samego Passportu, paczki odpowiedzialnej za możliwość użycia Google jako providera (pośrednika) oraz tej do obsługi sesji.

const passport = require('passport');
const GoogleStrategy = require('passport-google-oauth20').Strategy;
const session = require('express-session');

Następnie poinformujemy Passport, że powinien korzystać z mechanizmu Google. Od razu możemy też skonfigurować obiekt, który będzie wysyłany wraz z przekierowaniem. Musimy przekazywać trzy informacje: nasz klucz klienta (clientID), sekretny ciąg znaków i adres callback. Jeśli tego nie zrobimy, to próba komunikacji Passportu zostanie zignorowana przez Google, a użytkownik zamiast ekranu logowania, zobaczy komunikat z błędem. Podobnie będzie, gdy wyślemy właściwe informacje, ale z nieodpowiednią wartością, np. clientID niezgodny z sekretnym kluczem.

Warto dodać, że brak wpisania którejś z wymaganych informacji będzie od razu odnotowany przez Passport, który poinformuje nas o tym w konsoli. Niestety jednak – błędna wartość nie zostanie przez niego wychwycona, dlatego sami musimy pilnować się, aby podawać poprawne dane.

Aby odpowiednio skonfigurować Passport, skorzystamy z jego metody middleware passport.use.

passport.use(new GoogleStrategy({
    clientID: <GOOGLE_CLIENT_ID>,
    clientSecret: <GOOGLE_CLIENT_SECRET>,
    callbackURL: <CALLBACK_ADDRESS>
}, (accessToken, refreshToken, profile, done) => {
  done(null, profile);
}));

Zarówno clientID, jak i clientSecret, możesz znaleźć w witrynie Google APIs. Jako adres callback wpisz po prostu ten, który już wcześniej sobie założyliśmy, czyli http://localhost:8000/auth/google/callback. Choć możesz go nazwać dowolnie, pamiętaj jednak, aby w tym miejscu był on zawsze taki sam, jak ten wpisany przy tworzeniu klucza w Google APIs.

Po wprowadzeniu Twoich danych ten kod może wyglądać mniej więcej tak:

// configure passport provider options
passport.use(new GoogleStrategy({
    clientID: '9754491gfdgfdg5fdalmg9111fe3ahrbu.apps.googleusercontent.com',
    clientSecret: 'OfHsdfsdfgMZTLtlIMoe0',
    callbackURL: 'http://localhost:8000/auth/callback'
}, (accessToken, refreshToken, profile, done) => {
  done(null, profile);
}));

Najlepiej umieść go zaraz po inicjacji app.

Zapewne udało Ci się zauważyć, że oprócz obiektu z opcjami konfiguracji, w GoogleStrategy mamy też drugi parametr. Jest to funkcja callback, którą pośrednik będzie odpalał po zakończeniu autoryzacji, dzięki czemu serwer odpowiednio na takie zdarzenie zareaguje. Na razie jednak jeszcze się nią nie przejmuj, opowiemy o niej później.

Na końcu musimy uruchomić w Expressie mechanizm sesji oraz zintegrować działanie Passportu z naszą aplikacją. Jak zawsze, użyjemy do tego konceptu middleware'u.

app.use(session({ secret: 'anything' }));
app.use(passport.initialize());
app.use(passport.session());

Obsługę sesji w Expressie uruchamia session({ secret: 'anything' }). Parametr secret służy do jej bardziej unikalnego kodowania, ponieważ jest wykorzystywany przy generowaniu i odczytywaniu informacji o sesji. Jego nieprzewidywalność (sami decydujemy o treści) powoduje, że inne podmioty niż nasz serwer mają duży problem z jej odkodowaniem. Użyj tutaj dowolnej wartości.

Działanie paczki inicjuje passport.initialize()), natomiast passport.session()); odpowiada za rozpoczynanie sesji po zalogowaniu użytkownika, która będzie trwała nawet po przejściu z jednej podstrony na drugą. Wystarczy zalogować się raz, a serwer będzie o tym pamiętał – tak długo aż sami w końcu tę sesję zakończymy, np. poprzez zamknięcie przeglądarki albo kliknięcie na jakiś button "Wyloguj".

Aby jednak mechanizm sesji mógł działać poprawnie, musimy uruchomić jeszcze funkcjonalności serializacji i deserializacji. Użytkownik, który będzie się u nas logował to obiekt, który posiada kilka atrybutów, np. e-mail czy nazwę użytkownika. Nie możemy jednak zapisywać w sesji bezpośrednio obiektów JS-owych, stąd też będziemy musieli serializować te informacje do ciągu znaków. Poniższy kod o to zadba.

// serialize user when saving to session
passport.serializeUser((user, serialize) => {
  serialize(null, user);
});

// deserialize user when reading from session
passport.deserializeUser((obj, deserialize) => {
  deserialize(null, obj);
});

Umieść go pod blokiem kodu z passport.use.

Nie będziemy wchodzić tutaj w szczegóły. Tak naprawdę nas, jako developerów, nie bardzo interesuje działanie tych funkcji pod maską. Ważne jest, by kod w razie potrzeby po prostu serializował albo deserializował informacje o użytkowniku w sesji, aby mechanizm mógł pracować poprawnie. Passport sam zadba o uruchamianie ich wtedy, kiedy będą potrzebne.

Etap 1 – przekierowujemy użytkownika do pośrednika

Serwer jest już odpowiednio przygotowany, więc możemy przejść do istoty zadania.

Wspominaliśmy już, że najczęściej zaczyna się od kliknięcia na button połączony z endpointem, który przekieruje nas dalej do pośrednika. Tę część wykonamy właśnie teraz.

Modyfikujemy button

Zacznij od modyfikacji szablonu strony głównej index.hbs. Dodaj w nim atrybut href i spraw, aby kierował pod adres /auth/google. Takiego endpointu jeszcze nie ma, ale zaraz go dodamy. Oczywiście jak zawsze – sama nazwa jest dowolna, równie dobrze moglibyśmy tutaj użyć /loginwithgoogle albo /login/google.

Dodajemy endpoint

Teraz przejdźmy do serwera i dodajmy ten endpoint. Nie będzie on jednak zwykły jak te, które już są w naszym pliku. Jego celem jest bowiem od razu przekierowanie użytkownika do pośrednika. Osiągniemy to, korzystając z funkcji passport.authenticate.

app.get('/auth/google',
  passport.authenticate('google', { scope: ['email', 'profile'] }));

Powyższy kod można rozumieć jako rozkaz: po wejściu na link /auth/google przekieruj użytkownika do systemu autoryzacji Google i powiedz mu, że interesuje nas e-mail oraz informacje o profilu. Naturalnie przy samym przekierowaniu, Passport wyśle też pośrednikowi informacje, które skonfigurowaliśmy wcześniej, a więc clientID, clientSecret i callbackURL. Wszystko to będzie wykorzystywane przez providera w celu ustalenia, skąd w ogóle pochodzi to przekierowanie i kto prosi o autoryzację.

Uruchom teraz serwer, wejdź pod adresem localhost:8000 i spróbuj kliknąć na button "Login with Google".

image

No proszę, działa! Etap pierwszy mamy za sobą. Nasza aplikacja po kliknięciu na button poprawnie przekierowuje użytkownika do panelu autoryzacji Google. Przy okazji zauważ, że pośrednik poprawnie zinterpretował, kto przekierował użytkownika.

Etap 2 – autoryzacja

Etap drugi, czyli autoryzacja nie jest w naszych rękach i nie mamy na nią wpływu. Możemy od razu przejść do etapu trzeciego.

Etap 3 – odbieramy dane od pośrednika

Po autoryzacji, udanej lub nie, provider ponownie przekierowuje użytkownika do naszego serwera. Oprócz tego, w razie sukcesu, wysyła też informacje, o które prosiliśmy. W naszym przypadku jest to e-mail oraz dane profilu i musimy odpowiednio się na nie przygotować.

Przygotowujemy endpoint callback

Zacznijmy od przygotowania endpointu, do którego pośrednik przekieruje użytkownika po procesie autoryzacji. U nas miał być to /auth/google/callback.

app.get('/auth/google/callback', (req, res) => {
  res.send(`I'm back from Google!`);
});

Teraz ponownie spróbuj uruchomić serwer i kliknąć w button logowania. Po udanej autoryzacji powinniśmy być przekierowywani pod nasz nowy endpoint.

image

Sesja użytkownika

Możesz zauważyć, że po jednokrotnym zalogowaniu Google nie prosi nas już o autoryzację, tylko od razu przekierowuje pod adres callback. To żaden błąd, tylko właśnie zasługa mechanizmu sesji.

Jednak czy to wystarczy? Nie do końca. Chcielibyśmy, aby ten endpoint był tylko decydentem. Powinien otrzymywać informację od pośrednika, czy logowanie się udało i zależnie od tego przekierowywać użytkownika do podstrony sukcesu albo porażki.

Możemy to osiągnąć dość łatwo, korzystając ponownie z metody passport.authenticate. Pozwala ona nie tylko na przekierowanie użytkownika do pośrednika, ale też samo sprawdzenie, czy to zalogowanie się udało. Wystarczy, że w passport.authenticate zdefiniujemy jeszcze normalną obsługę requestu, a wtedy metoda będzie traktowana jako zwykły middleware, który sprawdzi, czy użytkownik jest zalogowany i czy należy go "przepuścić".

app.get('/auth/google/callback', passport.authenticate('google', { failureRedirect: '/user/no-permission' }),
  (req, res) => {
    res.redirect('/user/logged');
  }
);

Taki kod możemy rozumieć jak rozkaz: po powrocie użytkownika do /auth/google/callback, sprawdź czy udało się go zalogować; jeśli nie, to przekieruj go do /user/no-permission, jeśli tak to przekieruj go do /user/logged.

Zależnie od rezultatu autoryzacji, nasz serwer będzie od teraz kierował użytkownika do podstrony sukcesu albo porażki.

image

Odbieramy dane o użytkowniku

Oprócz przygotowania endpointu callback, mamy jeszcze jedno zadanie. Wiemy, że pośrednik po udanej autoryzacji przesyła do serwera informacje o użytkowniku, które mogą być wykorzystywane do różnych celów – np. do imiennego powitania albo dodania nowego użytkownika do bazy. Tak czy inaczej, musimy je jednak odebrać. Jak możemy to zrobić?

Pomoże nam tutaj już wcześniej wspomniana funkcja callback, która na razie czeka na nas prawie pusta w middleware passport.use. W przypadku udanej autoryzacji jest ona wywoływana od razu z odpowiednimi argumentami, w których kryją się informacje o użytkowniku. Musimy więc po prostu odpowiednio tę funkcję przygotować, aby miała parametry, do których te dane przyjmiemy.

Parametry mamy już gotowe, ale nie wiemy jeszcze, co tak naprawdę się w nich kryje.

passport.use(new GoogleStrategy({
  ...
}, (accessToken, refreshToken, profile, done) => {
  done(null, profile);
}));

Zacznijmy od accessToken. Po udanej autoryzacji Google generuje dla nas unikalny klucz, który może być wykorzystywany przez serwer do swobodnego pobierania informacji o użytkowniku. Serwer nie musi się autoryzować, po prostu łączy się (przy użyciu Passport) z Google kiedy chce, a serwis rozpoznawszy accessToken od razu zwraca dane.

To właśnie jest accessToken – unikalny kod, dzięki któremu serwer może w każdej chwili pobrać informację o danym użytkowniku.

A czym jest refreshToken? accessToken po jakimś czasie wygasa, dlatego Google dostarcza nam również refreshToken. Posiadając go, możemy poprosić o wygenerowanie nowego accessToken. Po co taka kombinacja? Dzięki temu nawet jeśli ktoś zdobędzie Twój accessToken, będzie mógł z niego korzystać tylko przez ograniczony czas.

W profile otrzymamy po prostu dane o użytkowniku, o które prosiliśmy.

Parametr done jest dla nas najbardziej enigmatyczny. To po prostu funkcja, której wywołanie poinformuje Passport, że może zająć się już inicjacją sesji. Przekazujemy tutaj informację o profilu użytkownika, bo właśnie na jego bazie utworzymy sesję. Oczywiście, jak zapewne się domyślasz, zanim ten profil będzie zapisany do sesji, Passport skorzysta z mechanizmu serializacji. Przy okazji zauważ, że to kolejna część konceptu, o którym mówiliśmy na początku. Serwer otrzymuje od pośrednika dane o użytkowniku i inicjuje sesję.

Pośrednik wywołuje funkcję? Ale jak?!

Mówiąc, że pośrednik wywołuje naszą funkcję callback w passport.use, posłużyliśmy się dość dużym skrótem myślowym. Jak zapewne się domyślasz, nie jest to możliwe, aby zewnętrzny serwer mógł wykonać jakąś funkcję schowaną w kodzie naszej aplikacji. Wygląda więc to trochę inaczej.

Tak naprawdę pośrednik po sukcesie autoryzacji tylko przekierowuje użytkownika z powrotem do strony pod adres callback. Jednak, co istotne, przekazuje przy tym w linku również accessToken. Dzięki temu Passport może sam połączyć się z Google i poprosić o dokładne dane o użytkowniku, a kiedy je otrzyma, odpala właśnie naszą funkcję callback w passport.use.

Nie jest więc prawdą, że pośrednik wywołuje tę funkcję po sukcesie autoryzacji, ale faktem jest, że właśnie po tym sukcesie i po przekierowaniu funkcja jest wywoływana. Inicjuje ją nie pośrednik, tylko nasz serwer po skomunikowaniu się z Google i pobraniu danych o użytkowniku.

W ramach treningu możesz dodać do funkcji callback linijkę console.log(profile). Wtedy, po udanej autoryzacji będziemy mogli zobaczyć w konsoli, jak dokładnie wygląda ten obiekt z danymi.

Sprzątamy w naszym kodzie

Wygląda na to, że założenia, o których mówiliśmy na początku, zostały spełnione. Aplikacja po kliknięciu na button "Login with Google" faktycznie przekierowuje nas do autoryzacji Google, a potem odpowiednio do podstrony sukcesu albo porażki. Dodatkowo potrafi inicjować sesję, tak aby nie trzeba było ciągle powtarzać logowania. Wydaje się więc, że nasza praca dobiegła końca. To prawda, chociaż dobrym pomysłem, byłoby trochę w tym kodzie posprzątać.

Zauważ bowiem, że po dodaniu wszystkich funkcjonalności związanych z Passportem, nasz plik server.js ogromnie się rozrósł. Pomyśl tylko, jak wyglądałoby to przy dodaniu większej ilości endpointów.

Wyciągamy na zewnątrz dane konfiguracyjne

Zacznijmy od wyciągnięcia na zewnątrz naszych danych konfiguracyjnych. Mowa tutaj o clientID, clientSecret oraz callbackURL.

Pierwsze co przychodzi nam do głowy, to utworzenie nowego pliku (np. o nazwie config.js), przeniesienie tych informacji tam, a następnie ich zaimportowanie. Czy to na pewno najlepszy pomysł?

Ukrywaj wrażliwe dane

Za nami submoduł, w którym omawialiśmy dobre praktyki pisania kodu, więc od razu może zapalić Ci się lampka ostrzegawcza. Jak to – chcemy przechowywać wrażliwe dane w zwykłym pliku? Faktycznie, nie jest to dobry pomysł. O wiele lepiej byłoby skorzystać ze zmiennych środowiskowych.

Używając Node'a, zawsze możemy zapisywać je w pliku .env. Jeśli jednak do uruchamiania serwera zastosujemy Nodemona, to istnieje również opcja umieszczenia ich w nodemon.json, w obiekcie env. Ten drugi sposób jest trochę bardziej wygodny w użyciu. Nie musimy importować niczego nowego do pliku, w którym chcemy korzystać z tych zmiennych, a same dane są zapisywane w przyjaznym formacie JSON.

Właśnie z tej drugiej opcji skorzystamy. Utwórz więc teraz nowy plik w swoim katalogu i nazwij go nodemon.json. Następnie wypełnij go następującymi danymi:

{
  "env": {
    "clientID": "my-client-id",
    "clientSecret": "my-client-secret",
    "callbackURL": "my-callback-url"
  }
}

Oczywiście wpisz tutaj swoje dane.

Następnie możemy wykorzystać te zmienne już bezpośrednio w pliku server.js.

passport.use(new GoogleStrategy({
    clientID: process.env.clientID,
    clientSecret: process.env.clientSecret,
    callbackURL: process.env.callbackURL,
}, (accessToken, refreshToken, profile, done) => {
  done(null, profile);
}));

Na końcu dodaj jeszcze nowy wpis do .gitignore, tak aby ignorował przy commitach plik nodemon.json. Jest to koniecznie, inaczej bowiem cała operacja nie miałaby sensu. Co z tego, że konfiguracja byłaby zapisywana w zmiennych środowiskowych, jeśli plik z nimi i tak byłby dostępny na publicznym repo...

Co dały nam ostatecznie te zmiany? Po pierwsze, plik server.js minimalnie się odchudził, a po drugie i ważniejsze, nasza aplikacja wciąż działa poprawnie, natomiast wrażliwe dane są już ukryte przed innymi.

Wyciągamy na zewnątrz konfigurację Passportu

Mimo zmian bardzo dużą część naszego pliku server.js zajmuje wciąż część dotycząca konfiguracji Passportu. Chodzi dokładnie o ten kod:

passport.use(new GoogleStrategy({
    clientID: process.env.clientID,
    clientSecret: process.env.clientSecret,
    callbackURL: process.env.callbackURL,
}, (accessToken, refreshToken, profile, done) => {
  done(null, profile);
}));

// serialize user when saving to session
passport.serializeUser((user, serialize) => {
  serialize(null, user);
});

// deserialize user when reading from session
passport.deserializeUser((obj, deserialize) => {
  deserialize(null, obj);
});

Dobrze byłoby go wyciągnąć na zewnątrz.

Stwórz nowy folder o nazwie config i dodaj do niego plik passport.js. Następnie umieść w nim cały kod wspominany przed chwilą. Potrzebne będą też odpowiednie importy.

const passport = require('passport');
const GoogleStrategy = require('passport-google-oauth20').Strategy;

Następnie wróć do pliku server.js, usuń niepotrzebny skrypt i zamień na odpowiednie importy. Możesz też pozbyć się importu GoogleStrategy, z którego już nie korzystamy bezpośrednio w tym pliku.

const passportConfig = require('./config/passport');

Serwer powinien działać tak samo, a udało nam się dzięki temu znacząco odchudzić plik server.js.

Jak to w ogóle działa?

To, co zrobiliśmy przed chwilą, mogło Cię wprawić w zakłopotanie. Jeszcze większe zdziwienie mogło się pojawić po uruchomieniu serwera, gdyż nasz nowy kod... faktycznie działa.

Zawsze mówiliśmy, że aby dało się zaimportować jakąś funkcję czy obiekt, trzeba je najpierw wyeksportować. Jak to możliwe, że teraz wystarczyło zaimportować plik, i ten mimo braku jakiekolwiek eksportu, zwyczajnie się wykonał?

No cóż... Node.js tak po prostu działa.

Gdy zaimportujemy plik, to jeśli jest w nim jakiś "luźny" kod (a więc nieschowany w funkcji czy obiekcie), zostanie on automatycznie skompilowany. To prawie tak samo, jak przy ładowaniu plików JS w HTML-u za pomocą tagu <script>. Wystarczyło dodać odpowiedni tag z dobrą ścieżką do pliku i po wejściu na stronę, kod wewnątrz niego automatycznie się kompilował.

Node.js daje nam możliwość eksportowania funkcji czy obiektów i importowania ich potem pod odpowiednią nazwą, ale to tylko przydatna funkcjonalność, a nie wymóg.

Pojawia się więc pytanie, dlaczego w takim razie tak chętnie korzystamy z idei eksportów? Odpowiedź jest prosta. Daje nam to możliwość czytelnego przypisania jakichś funkcjonalności (lub danych) do stałych i wykorzystania ich potem w dalszym kodzie, w miejscu, gdzie jest to nam potrzebne.

Tymczasem w przypadku ładowania bez eksportu, wykonywanie tego "luźnego" kodu następuje od razu w miejscu, gdzie ten plik jest zaimportowany (więc w miejscu wywołania require). W większości sytuacji byłaby to ogromna wada, nie wspominając już o zwyczajnie gorszej czytelności.

Zauważ, że gdy ktoś wejdzie do naszego server.js, nie jest w stanie od razu stwierdzić, do czego w ogóle jest nam potrzebny plik config/passport. Można to sprawdzić dopiero po wejściu do tego pliku. Często wolimy postawić więc na większą przejrzystość.

Podsumowując, gdy używamy require wcale nie musimy korzystać z idei eksportów, lecz najczęściej będzie to zwyczajnie lepszym pomysłem – czytelniejszym i bardziej funkcjonalnym.

Wydzielamy endpointy

Nasz server.js jest coraz mniejszy, możemy zrobić jednak jeszcze jedną rzecz.

W poprzednich modułach standardem było wydzielanie grup endpointów do zewnętrznych plików. Możemy to zrobić również teraz, bo w końcu mamy dwie specjalne grupy – auth i user.

Stwórz więc nowy folder (routes), a w nim dwa nowe pliki (auth.routes.js i user.routes.js).

Pierwszy powinien mieć następującą zawartość:

const express = require('express');
const passport = require('passport');
const router = express.Router();

router.get('/google',
  passport.authenticate('google', { scope: ['email', 'profile'] }));

router.get('/google/callback', passport.authenticate('google', { failureRedirect: '/user/no-permission' }),
  (req, res) => {
    res.redirect('/user/logged');
  }
);

module.exports = router;

Tak powinien wyglądać drugi:

const express = require('express');
const router = express.Router();

router.get('/logged', (req, res) => {
  res.render('logged');
});

router.get('/no-permission', (req, res) => {
  res.render('noPermission');
});


module.exports = router;

Czy są tu jakieś nowości? Na razie nie, bo robiliśmy już takie wydzielanie route'ów. Czymś nowym jest tylko potrzeba zaimportowania Passportu do pierwszego z plików. Niemniej jednak, akurat to pewnie jest dla Ciebie zupełnie logiczne. Jeśli chcemy skorzystać z metody wbudowanej w paczkę passport, musimy mieć do niej dostęp.

Teraz wystarczy jeszcze zaimportować te endpointy z powrotem do server.js:

app.use('/auth', require('./routes/auth.routes'));
app.use('/user', require('./routes/user.routes'));

Taki zapis to oczywiście skrót od:

const authRoutes = require('./routes/auth.routes');
const userRoutes = require('./routes/user.routes');

...

app.use('/auth', authRoutes);
app.use('/user', userRoutes);

Ostatecznie udało nam się całkiem nieźle odchudzić plik server.js, a serwer – co najważniejsze – wciąż działa dobrze. Ostatecznie ten plik powinien wyglądać mniej więcej tak:

const express = require('express');
const cors = require('cors');
const path = require('path');
const hbs = require('express-handlebars');
const passport = require('passport');
const session = require('express-session');
const passportSetup = require('./config/passport');

const app = express();

// set handlebars as view engine
app.engine('hbs', hbs({ extname: 'hbs', layoutsDir: './layouts', defaultLayout: 'main' }));
app.set('view engine', '.hbs');

// init session mechanism
app.use(session({ secret: 'anything' }));

// init passport
app.use(passport.initialize());
app.use(passport.session());

// standard middleware
app.use(cors());
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.use(express.static(path.join(__dirname, '/public')));

app.get('/', (req, res) => {
  res.render('index');
});

app.use('/auth', require('./routes/auth.routes'));
app.use('/user', require('./routes/user.routes'));

app.use('/', (req, res) => {
  res.status(404).render('notFound');
});

app.listen('8000', () => {
  console.log('Server is running on port: 8000');
});

Podsumowanie

Jak widzisz, ostatecznie nasz kod dość dobrze pokrył się z tym, jak opisywaliśmy koncept działania OAuth 2.0. Owszem, całość była trochę bardziej skomplikowana w implementacji, ale jest to stosunkowo niewielka cena za korzystanie z tak zaawansowanego systemu autoryzacji.

Warto zauważyć, że było to z pewnością łatwiejsze niż zaimplementowanie takiego systemu od zera. Po początkowej integracji Passportu z naszym serwerem korzystanie z autoryzacji jest już stosunkowo proste, o czym zresztą przekonasz się za chwilę, wykonując zestaw kilku praktycznych zadań.

Zadanie: praktyka czyni mistrza

Czas na przećwiczenie nowej wiedzy. Poniżej mamy dla Ciebie kilka zadań, które rozwiną naszą prostą aplikację. Postaraj się wykonać je najpierw bez naszej pomocy, korzystając z materiału submodułu, dokumentacji Passportu oraz zdrowego rozsądku ;)

Zadanie 1

Twoim zadaniem jest dodanie dwóch nowych podstron – /user/profile/ oraz /user/profile/settings. Ich treść może być dowolna, wystarczą proste nagłówki. Co istotne jednak, obie strony powinny być dostępne tylko po zalogowaniu. Przy próbie wejścia pod ich adresy przez nieautoryzowanych gości, serwer musi przekierowywać takie osoby do podstrony /user/no-permission.

Dodatkowo, taką samą taktykę zastosuj również dla user/logged. Ta podstrona nie powinna być widoczna dla niezalogowanych użytkowników.

Z dokumentacji możesz się dowiedzieć, że Passport inicjując sesję użytkownika, zapisuje informacje o nim do req.user. Jest to dla nas bardzo ważna wskazówka. Możemy bowiem wywnioskować, że wystarczy sprawdzić req.user, aby stwierdzić jaki jest status użytkownika. Jeśli nie będzie puste to możemy być pewni, że gość został zautoryzowany. Proste, prawda? ;)

Teraz wystarczy połączyć tę wiedzę z informacją, że możemy przekierowywać użytkownika do innych podstron za pomocą res.redirect.

Dla ambitnych

Istnieje możliwość ograniczenia powtarzalności kodu. Wystarczy stworzyć własny middleware, który będzie po prostu sprawdzać, czy req.user nie jest puste i zależnie od tego, przekierowywać użytkownika do /user-no-permisson albo pozwalać mu przejść dalej do właściwego endpointu (next()). Wtedy zamiast pisać warunkowy kod ciągle od nowa, moglibyśmy wykorzystywać w wybranych endpointach nasz nowy middleware.

Nasz middleware powinien wyglądać mniej więcej tak:

const isLogged = (req, res, next) => {
  if(condition){
    res.redirect('/proper-url');
  } else {
    next();
  }
};

Pamiętaj, że middleware możesz wykorzystywać dla grup endpointów, a więc np.

app.use('/some-url') (req, res, next) => {
  if(condition){
    res.redirect('/proper-url');
  } else {
    next();
  }
});

ale także dla pojedynczych endpointów:

const isLogged = (req, res, next) => {
  if(condition){
    res.redirect('/proper-url');
  } else {
    next();
  }
};

app.get('/admin', isLogged, (req, res) => {
  //do something after being authorized
});

Właśnie o tę drugą chodzi nam w tym zadaniu.

Zadanie 2

Kolejne zadanie jest już trochę krótsze. Do aplikacji należy dodać nowy endpoint /auth.logout, który będzie kończył sesję użytkownika. Link do niego powinien zostać dodany do podstrony user/logged.

Przy wykonaniu zadania wspomóż się dokumentacją Passport.js.

Zadanie 3

Ostatnie wyzwanie również będzie stosunkowo krótkie. Twoim zadaniem jest taka modyfikacja pliku user/logged, aby zamiast komunikatu You have been successfully logged in!, pokazywał awatar użytkownika oraz napis Hello username!. Oczywiście w miejscu username ma być po prostu imię i nazwisko logującego się użytkownika, a więc np. Hello John Doe!.

Ma to wyglądać mniej więcej tak:

image

Aby ułatwić Ci pracę, poniżej mamy dla Ciebie klasę stylującą obrazek:

.avatar {
  width: 7rem;
  height: 7rem;
  border-radius: 50%;
  box-shadow: 0 0 10px 10px rgba(0,0,0,0.1);
  object-fit: cover;
}

  1. Pamiętaj, że wszystkie dane o zalogowanym użytkowniku znajdziesz w req.user.
  2. Dane otrzymane od Google i przechowywane w req.user mają strukturę, której nie znamy. Możesz więc dla ułatwienia wypisać sobie zawartość tego obiektu w konsoli. Pomoże Ci to dojść do odpowiednich atrybutów.
  3. W aplikacji zastosowano system szablonów Handlebars, możesz więc wykorzystać jego ideę placeholderów, aby przekazać przy renderowaniu odpowiednie informacje do widoku. W razie problemu wróć po pomoc do modułu o Expressie.

31.5. Po drugiej stronie barykady

Do tej pory, gdy mówiliśmy o bezpieczeństwie aplikacji, zawsze stawialiśmy się w roli broniących, czyli osób, które muszą przewidzieć zagrożenia i odpowiednio się na nie przygotować. Tym razem podejdziemy do tematu od drugiej strony i... wcielimy się w rolę atakującego.

Zakładamy (a przynajmniej mamy nadzieję), że nie jest to droga, którą chcesz podążać. Niemniej jednak poznanie wszystkiego również od drugiej strony, powinno uświadomić Ci, na co musimy być przygotowani. Dobre praktyki to tylko teoria, a teraz chcemy przekonać się, jak jej zignorowanie czyni naszą aplikację bezbronną. To podejście rodem z policyjnych podręczników – jeśli chcesz złapać przestępcę, naucz się myśleć jak on. Gdy chcesz dobrze zabezpieczyć aplikację i poprawnie przewidzieć zagrożenia, musisz umieć wcielić się w atakującego.

Dlatego też w tym submodule na chwilę zmienimy barwy i staniemy się "hakerem". Zajmiemy się próbą znalezienia luk bezpieczeństwa w gotowej aplikacji, jednak aby nauka nie szła w las, każdą znalezioną dziurę postaramy się załatać. Brzmi ekscytująco, prawda? ;)

Kogo atakujemy?

Naszym celem będzie witryna konkursu fotograficznego. Główną funkcjonalnością tej aplikacji jest pokazywanie prac na stronie głównej oraz formularz do dodawania zgłoszeń na podstronie "Submit a photo".

image

Co chcemy przetestować?

Nie będziemy interesować się wszystkimi możliwymi lukami. Postaramy się zbadać bezpieczeństwo w tych obszarach, gdzie jest ona dla twórców najbardziej istotne.

Przetestujemy dwa aspekty:

  1. Poprawność działania formularza – czy użytkownik jest ograniczony zgodnie z założeniami, a więc, czy np. formularz wymusza wpisanie e-maila, nazwy użytkownika i nie pozwala na dodawanie zgłoszenia bez wysłania zdjęcia.
  2. Poprawność działania systemu "lajków" – każde zdjęcie może zostać polubione, jednak twórcy witryny chcą, aby można to było zrobić tylko raz. W przeciwnym razie uczestnicy byliby w stanie sztucznie pobijać sobie statystyki.

Scenariusze ataku

Jak podejdziemy do tych testów? Postaramy się zasymulować dwa scenariusze ataków.

Złośliwy atak konkurencji

W pierwszym z nich postaramy się zasymulować klasyczny atak na "wyniszczenie". Wcielimy się w atakującego, który ma powiązania z konkurencją i chce po prostu w jakiś sposób zaburzyć działanie strony. Nie jest to atak nastawiony na kradzież danych czy jakieś korzyści finansowe. Bardziej chodzi o zaszkodzenie w taki sposób, aby strona przestała działać poprawnie. Przykładem takiego ataku, może być np. próba dodania wadliwych opisów zdjęć, aby "wepchnąć" do nich jakiś kod HTML z atrybutem style, który następnie mógłby powodować np. zakrycie całej strony czarnym tłem. Tego typu przykład już nawet w tym module pokazywaliśmy.

W przypadku takiego uderzenia będziemy pracować głównie z formularzem dodawania zdjęć oraz z samym serwerem, bo to właśnie te części strony są odpowiedzialne za przyjmowanie danych.

Nieuczciwy uczestnik

Drugi przykład jest jeszcze bardziej życiowy. Będzie symulować sytuację, w której atakującym jest jeden z uczestników, chcący nieuczciwie zwiększyć ilość polubień swojego zdjęcia. Tutaj motyw jest typowo finansowy, organizatorzy konkursu przygotowali bowiem ciekawe nagrody.

W tym przypadku najprawdopodobniej skupimy się na stronie głównej i analizie tego, co dzieje się w przypadku kliknięcia na "serduszko". Następnie będziemy starali się oszukać aplikację, żeby pozwalała na wielokrotne głosowanie na to samo zdjęcie.

Przygotowania do pracy

Zacznij od pobrania całej aplikacji. Możesz to zrobić pod tym linkiem.

Uruchom komendę yarn install, aby pobrać potrzebne paczki i odpal podgląd aplikacji za pomocą tasku start.

W tym miejscu musimy jeszcze poruszyć dwie kwestie.

Ślepy atak

Mimo że masz dostęp do kodu źródłowego, podczas ataku staraj się do niego nie zaglądać. Mamy w końcu udawać hakera, a ten zazwyczaj nie ma o nim żadnej wiedzy i uderza na ślepo.

Poprawki bezpieczeństwa

Jak już mówiliśmy, po każdej wykrytej luce przeznaczymy chwilę na jej naprawę. Będzie to wymagało modyfikacji w kodzie źródłowym aplikacji, warto więc powiedzieć o niej kilka słów.

Część kliencka jest zbudowana za pomocą Reacta, wykorzystuje również Reduksa i React Router. Backend został oparty o framework Express.js, dane przechowywane są w bazie MongoDB, a dostęp do nich jest realizowany poprzez Mongoose. Brzmi znajomo, prawda?

Jeden cel, wiele dróg

Uwaga! Mimo tego, że aplikacja jest zbudowana w znanych Ci już technologiach, niektóre jej części mogą być zakodowane w trochę inny sposób niż znany nam z kursu. To celowy zabieg. W swojej pracy bardzo często natrafisz na sytuację, w której różne osoby czy teamy trochę inaczej pochodzą do pewnych aspektów aplikacji albo znają inne "dobre" praktyki. To wcale nie znaczy, że jedne czy drugie są gorsze. W programowaniu jest wiele dróg do osiągnięcia jednego celu. Nasz przykład pozwoli Ci sprawdzić, jak dobrze rozumiesz dotychczasowy materiał.

Złośliwy atak konkurencji

Wiemy już, co chcemy przetestować i jak do tego podejdziemy. Możemy więc brać się do pracy. Zaczniemy od pierwszego scenariusza, a więc symulacji ataku złośliwej konkurencji.

Przypomnijmy, że mamy tutaj na celu wprowadzenie takich danych, które w jakiś sposób "popsują" stronę. Możemy spróbować dodać np. zdjęcie z za długą nazwą, tak aby wystawała poza boksy z grafikami, albo dodać jakiś złośliwy kod HTML z atrybutem style, który wykorzystując position: fixed i background zakryje całą stronę czarnym tłem. Musimy jakoś złamać aplikację, by pozwoliła nam wysłać wadliwe dane do serwera, który je przyjmie.

Zacznijmy od przejścia na podstronę "Submit a photo".

image

Próby do przeprowadzenia

Zaplanujmy ataki, które możemy sprawdzić:

  1. Dodawanie pustych zgłoszeń – puste boksy na stronie, bez zdjęcia i bez napisu dość mocno kompromitowałyby konkurs.
  2. Dodawanie zgłoszeń bez zdjęcia – jeśli aplikacja nie pozwoli nam na puste pola tekstowe, możemy spróbować dodać zgłoszenia bez zdjęć. To też dałoby kompromitujący efekt.
  3. Dodawanie zgłoszeń z wadliwym plikiem – jeśli plik musi być wysłany, to możemy chociaż spróbować załączyć coś innego niż obrazek.
  4. Dodawanie zgłoszeń z bardzo długim tytułem – jeśli wcześniejsze ataki się nie powiodą, możemy spróbować przynajmniej tego.
  5. Dodawanie zgłoszeń ze złośliwym HTML – o tym już mówiliśmy. To warte spróbowania, zważywszy na duże możliwości manipulacji stroną w razie sukcesu.

A więc do dzieła!

Dodawanie pustych zgłoszeń

Zacznijmy od najprostszego sposobu. Kliknij na Submit your work bez wypełniania formularza. Wiara, że twórcy w ogóle go nie walidują jest dość naiwna, ale... i tak warto spróbować. Kto wie, może akurat ktoś o tym zapomniał?

image

No cóż... Mogliśmy się tego spodziewać :) Da się tu jednak zauważyć istotną rzecz. Ten komunikat nie wygląda jak dodatkowy HTML, tylko coś, co jest pokazywane przez przeglądarkę. Może to sugerować, że zatrzymała nas zwykła podstawowa walidacja, którą przeglądarka oferuje w HTML5. To z kolei sugerowałoby, że za blokowanie nas odpowiada po prostu atrybut required tego pola.

Spróbujmy więc znaleźć ten formularz w inspektorze.

image

Faktycznie. Powstrzymał nas zwykły atrybut required, a z tym możemy sobie bardzo łatwo poradzić. Wiesz, że inspektor pozwala na modyfikację podglądu strony, usuń więc ten atrybut z każdego z pól formularza i spróbuj ponownie wysłać zgłoszenie bez jego wypełniania.

image

Coś udało nam się osiągnąć. Aplikacja nie zwróciła nam uwagi, że pola autor czy e-mail są puste, ale niestety zauważyła, że nie wybraliśmy obrazka. Wygląda na to, że przy wysyłaniu formularza sprawdza jeszcze za pomocą JS-a, czy to pole jest wybrane.

Co z tym zrobić? Możemy wyłączyć JS-a, ale całkiem możliwe, że przechwycenie przez niego eventu submit odpowiada też za samą wysyłkę formularza. Wyeliminowanie JavaScriptu blokowałoby więc też sam request do serwera. Lepszym wyborem może być więc próba oszukania kodu i zrobienie z inputu plikowego dla obrazka, inputu tekstowego. W teorii mogłoby to zadziałać, bo sama wartość nie byłaby już pusta. Spróbujmy.

Input dla obrazka jest tutaj trochę "zaszyty". Bez problemu możesz go jednak znaleźć w prawej kolumnie.

image

Gdy już go zlokalizujesz, zmień jego type na text i wpisz jako value dowolny tekst.

Niestety okazuje się, że aplikacja jest na taką zmianę odporna. Wciąż pokazuje, że zdjęcie jest niewybrane...

Postarajmy się więc dotrzeć do serwera naokoło. Nie musimy przecież polegać na aplikacji klienta, aby to ona wysyłała odpowiedni request. Jeśli dowiemy się, z jakim adresem łączy się przy dodawaniu zgłoszenia klient, to będziemy mogli zasymulować taki request sami, poza aplikacją, np. przy użyciu Postmana. Wtedy nic nie będzie nas już blokowało.

Musimy zacząć od ustalenia właściwego adresu. Jak możemy to zrobić? Wystarczy, że otworzysz zakładkę Network w narzędziach developerskich, a następnie postarasz się dodać poprawne zgłoszenie. Wtedy zobaczysz w niej informację o wysyłanym requeście.

image

Wiemy już, z jakim adresem mamy się połączyć, jakie dane wysyłać wraz z requestem, a nawet jaki ma być ich typ (formData). W takim razie nie pozostaje nam nic innego jak z tego skorzystać.

Otwórz Postmana i przygotuj odpowiedni request.

image

Następnie spróbuj go wysłać.

Niestety okazuje się, że serwer jest na taki atak odporny. Najwidoczniej sprawdza on, czy otrzymane dane nie są puste. Pierwsza próba ataku zakończyła się naszą porażką.

Dodawanie zgłoszeń bez zdjęcia

No dobrze, ale może przepuści nas chociaż bez zdjęcia?

Skoro jesteś już w Postmanie, to postaraj się teraz poprawnie wypełnić dane tekstowe, a więc author, title i email. Pozostaw jednak puste pole file. Następnie znowu wyślij request.

image

I znowu serwer nas nie przepuścił... Ponownie dał sobie z nami radę.

Dodawanie zgłoszeń z wadliwym plikiem

No dobrze... Wygląda na to, że plik musi być wysłany. Niemniej jednak zawsze jest szansa, że serwer nie sprawdza, czy to faktycznie plik graficzny. Spróbuj w Postmanie wybrać tutaj jakiś inny plik, np. tekstowy.

I okazuje się, że mamy sukces!

image

Jak widzisz, twórcy aplikacji nie zignorowali całkiem kwestii bezpieczeństwa. Wzięli pod uwagę, że klient niekoniecznie musi dostarczyć bezpieczne dane. Spodziewali się, że po drodze, ktoś może nimi manipulować. Mimo tego jednak, że pojawiła się jakaś walidacja na serwerze, która poradziła sobie z naszymi dwoma pierwszymi próbami, okazało się, że znalezienie luki nie było aż takie trudne. Najwyraźniej serwer sprawdza, czy został przesłany plik, ale nie waliduje jego rozszerzenia.

Niby to nie duża luka, ale dzięki niej udało nam się wprowadzić na stronę element bez obrazka.

image

Spójrz tylko. Wystarczy teraz wysłać taki request kilka razy i strona zacznie wyglądać na całkowicie popsutą.

Ćwiczenie 1

Pamiętaj, że mamy nie tylko szukać luk w zabezpieczeniach, ale też je łatać. Twoim zadaniem jest więc odnalezienie właściwego pliku na serwerze, który obsługuje ten endpoint i zadbanie o to, aby sprawdzał, czy rozszerzenie pliku to gif, jpg lub png. W przypadku innego formatu serwer powinien działać tak samo, jak przy próbie wysłania pustych danych.

Zauważ, że stała fileName powinna dać Ci całą nazwę pliku wraz z rozszerzeniem, np. test.jpg.

const fileName = file.path.split('/').slice(-1)[0];

Wystarczy więc użyć komendy .split, tak aby podzielić całość na dwie części. Ostatnia z nich będzie właśnie rozszerzeniem.

const fileExt = fileName.split('.').slice(-1)[0];

Dodawanie zgłoszeń z bardzo długim tytułem

Po zmianach request bez poprawnego pliku już nie przejdzie, ale być może wciąż da się skorzystać z innych luk? Sprawdźmy, jak sprawa ma się z długością tytułu. Łatwo możesz zorientować się, że wpisując go w formularz, blokowane jest wszystko powyżej 25 znaków. Niemniej jednak korzystając z Postmana od razu mamy to ograniczenie z głowy. Sprawdźmy, czy serwer będzie walidował poprawność długości tytułu.

Spróbuj wybrać teraz dobry plik obrazka (file), ale zmień w swoim requeście tytuł na dłuższy niż 25 znaków.

image

Wygląda na to, że mamy kolejną lukę...

Ćwiczenie 2

Naprawienie tej dziury to kolejne Twoje zdanie. Musisz ponownie zmodyfikować endpoint odpowiedzialny za dodawanie zgłoszenia, tak aby sprawdzał, czy długość tytułu jest mniejsza lub równa 25 znaków. Jeśli jest większa, to serwer powinien pokazać błąd. Warto podobną walidację zastosować też dla autora. Tutaj ustaw maksymalną ilość znaków na 50.

Dodawanie zgłoszeń ze złośliwym HTML

Po zmianach serwer powinien już radzić sobie z próbą wprowadzania przydługich tytułów. Wciąż nie wiemy jednak, czy poradzi sobie z kolejną próbą ataku – wadliwym HTML-em. Spróbuj znowu zmodyfikować request, ale tym razem w taki sposób, że w tytule wpiszesz jakiś kod HTML. Na razie może to być cokolwiek. Po prostu sprawdźmy, czy serwer w ogóle na takie coś pozwoli, np. dodaj zgłoszenie o tytule <h1>Lorem Ipsum</h1>.

image

Okazuje się, że ponownie serwer nie podołał. Wygląda na to, że radził sobie tylko z klasycznymi próbami ataku. Kiedy jednak proponujemy coś bardziej wyrafinowanego, serwer bardzo szybko ujawnia swoje braki w bezpieczeństwie.

Co prawda, akurat tutaj całość trochę ratuje React.

image

Widzimy bowiem, że nie renderuje kodu HTML otrzymanego od serwera. Tym samym nie musimy się też bać, że wzięty byłby pod uwagę element ze złośliwym atrybutem style. Niemniej jednak i tak nie wygląda to za dobrze...

Ćwiczenie 3

Na szczęście Ty, po lekturze pierwszych podrozdziałów z tego modułu, już wiesz, jak można sobie z tym radzić. Dlatego też Twoim zadaniem jest ponownie taka naprawa wadliwego endpointu, aby właściwie escape'ował wszystkie otrzymywane od klienta dane. Możesz również po prostu sprawdzać za pomocą wyrażenia regularnego, czy otrzymuje odpowiednie informacje, np. e-mail powinien być tylko ciągiem liter lub kropek, rozdzielonych do tego znakiem @.

Wnioski

Pierwsza część naszych testów bezpieczeństwa przynosi dwa ważne wnioski.

Po pierwsze, przy pisaniu aplikacji naprawdę łatwo o luki w bezpieczeństwie. Nawet jeśli staramy się chociaż podstawowo walidować otrzymywane od klienta dane, często jest to zbyt mało.

Po drugie, dobre praktyki omawiane w pierwszym podrozdziale... naprawdę mają sens. Zauważ, że wszystkie luki wzięły się stąd, że autor kodu nie zastosował się do kilku podstawowych zasad. Zwróć uwagę, że trzymając się standardu podwójnej dokładnej walidacji, nieufania klientowi oraz escape'owania wszystkich otrzymywanych danych, autor stworzyłby aplikację, która dałaby sobie radę z każdą z naszych prób.

Nieuczciwy uczestnik

Czas na drugi scenariusz ataku. Tym razem wcielimy się w rolę nieuczciwego uczestnika konkursu. Postaramy się znaleźć i wykorzystać niedoskonałości systemu w taki sposób, aby pozwolił na wielokrotne głosowanie na naszą pracę. Dzięki temu będziemy w stanie osiągnąć nieuczciwą przewagę nad konkurencją, na którą każdy odwiedzający może oddać tylko jeden głos.

Analiza funkcjonalności

Zacznijmy od przeanalizowania, jak mniej więcej działa cała funkcjonalność. Oczywiście mówimy wyłącznie o części klienta, bo tylko do niej atakujący ma w normalnej sytuacji dostęp.

Rozpoczniemy od sprawdzenia tego, co widzi zwykły użytkownik.

image

Po najechaniu na zdjęcie otrzymuje on opcję kliknięcia serduszka, co pozwala dodać polubienie. Naciśnięcie powoduje zwiększenie widocznej liczby lajków (z 7 do 8 na naszym przykładzie), ale też zniknięcie samego przycisku. Użytkownik nie ma już nawet fizycznej opcji spróbowania po raz kolejny. Co istotne, po odświeżeniu stan rzeczy się utrzymuje, a więc liczba głosów wciąż jest zwiększona, a serduszko na zdjęciu nadal jest niewidoczne. Możemy więc przypuszczać, że klient w jakiś sposób komunikuje się z serwerem, by poinformować o lajkowaniu.

Jesteśmy w stanie to łatwo ustalić. Wystarczy, że otworzysz zakładkę Network i pozostawisz ją otwartą podczas próby polubienia kolejnego zdjęcia.

image

Zgodnie z naszymi przypuszczeniami, kliknięcie na serduszko inicjuje request do serwera, ale wygląda on bardzo prosto. Nie są wysyłane żadne dodatkowe informacje, a serwer wie, o jaki obrazek chodzi dzięki przekazaniu id poprzez adres. Pojawia się tylko jedno pytanie. Czy wraz ze zwiększeniem liczby głosów, serwer zapisuje też gdzieś informację o tym, że to właśnie my zagłosowaliśmy? Jeśli tak, to ciężko będzie nam go oszukać, że jesteśmy kimś innym. Możliwe jednak, że autor kodu wcale nie wykorzystuje do tego zadania serwera, tylko np. używa zwykłych ciasteczek. To znacznie ułatwiłoby nam sprawę. Postaramy się to sprawdzić.

Próby do przeprowadzenia

Zaplanujmy naszą pracę. Postaramy się sprawdzić kilka możliwych luk:

  1. Ciasteczka – możliwe, że autor strony zdecydował o tym, że to ciasteczka będą trzymały informację o zdjęciach, na które zagłosowaliśmy. Ich usunięcie powinno wtedy pozwolić nam na ponowne zagłosowanie.
  2. localStorage – trochę ciekawszym, ale również łatwym do obejścia pomysłem, byłoby wykorzystanie localStorage, który także możemy wyczyścić. W razie porażki z ciasteczkami warto to sprawdzić.
  3. Request poza klientem – jeśli okaże się, że to coś innego, czego nie możemy wykryć, zawsze jest nadzieja, że blokada znajduje się w aplikacji klienta. Wtedy możemy liczyć, że request poza klientem to obejdzie.

Do dzieła!

Ciasteczka

Zacznijmy od sprawdzenia pierwszej możliwości. Najprostsze zablokowanie ponownego głosowania rzeczywiście może być zrobione przy użyciu ciasteczek, ale ma to sens tylko wtedy, gdy zakładamy, że nasi użytkownicy są zwykłymi internautami. Zawsze znajdą się jednak bardziej zaznajomieni z tematem, więc nie powinniśmy opierać istotnych funkcjonalności na ciasteczkach.

Tak czy inaczej, sprawdźmy to. Wejdź do narzędzi developerskich swojej przeglądarki i odnajdź zakładkę Application, a w niej samą opcję Cookies. Rozwiń ją i wskaż, że chodzi dokładnie o localhost:3000.

image

Okazuje się, że w istocie jakieś ciasteczka na naszej witrynie są. Niestety akurat _ga to nazwa używana przez Google Analytics, serwis obsługujący statystki witryn. Raczej wątpliwe, by autor strony chciał nas oszukać i skorzystał właśnie z takiej nazwy. Drugie, czyli io też nie sugerują nam tego, po co przyszliśmy. Mimo to jednak możesz spróbować je usunąć.

Jaki jest efekt? Nic się nie zmieniło. Wygląda więc na to, że w taki sposób serwera nie oszukamy.

LocalStorage

Skoro już jednak jesteśmy w zakładce Application, warto sprawdzić również drugą możliwość, a więc localStorage. Tutaj sytuacja jest już znacznie bardziej optymistyczna.

image

Wśród wielu pozornie bezużytecznych dla nas informacji, znajduje się prawdopodobnie ta, której szukaliśmy – tablica votes. Spójrz na jej wartość. Wygląda na to, że przechowuje informacje o tym, na kogo głosowaliśmy.

Sprawdźmy ten trop. Usuń wpis votes z localStorage i odśwież stronę. Jeśli nasze podejrzenia się sprawdzą, powinniśmy móc ponownie polajkować te same zdjęcia.

Wygląda na to, że... trafiliśmy! Autor zapomniał o jednej ważnej zasadzie – nie ufaj klientowi. Jak widzisz zarówno idea z ciasteczkami, jak i localStorage, to słaby pomysł, łatwy do obejścia. Zauważ, że wystarczyło wyczyścić te informacje i po sprawie. Można było również wykonać request spoza klienta i osiągnęlibyśmy taki sam efekt. Nie musimy więc nawet sprawdzać tej trzeciej możliwości.

Ćwiczenie 1

Skoro mamy już lukę, musimy ją teraz załatać. To będzie właśnie Twoje zadanie.

Postaramy się wykorzystać serwer do pilnowania lajków. Idea będzie całkiem prosta. Przy kliknięciu na serduszko, serwer najpierw sprawdzi, czy użytkownik o danym IP już głosował. Jeśli tak było, to proces lajkowania zostanie zablokowany.

Jak powinno wyglądać to w praktyce? Wykorzystamy w zadaniu bazę danych. Przy każdym kliknięciu na serce zapiszemy informację z jakiego IP zagłosowano na jakie zdjęcie. Dzięki temu przy procesie polubienia zawsze będziemy w stanie sprawdzić w bazie, czy głos na dane zdjęcie został już oddany.

Oczywiście adres IP to nie jest coś, czego nie da się oszukać, ale jednak taki bufor powinien odstraszyć już pewną rzeszę śmiałków.

Etap 1

Dodaj do serwera nowy model danych Voter (kolekcja voters), który będzie posiadał dwa pola – user oraz votes. Pierwsze zapisze adres IP użytkownika, jakiego tyczy się dany dokument, a drugie będzie tablicą zawierającą informacje, na jakie fotografie ta osoba zagłosowała, w postaci listy identyfikatorów tych zdjęć.

Etap 2

Przy próbie lajkowania zdjęcia, sprawdzaj czy w kolekcji voters znajduje się już wpis dla danego adresu IP. Jeśli nie, to stwórz go i dodaj od razu do jego atrybutu votes identyfikator polubionego zdjęcia. Pozwól też na normalną modyfikację ilości głosów, czyli krótko mówiąc, jeśli osoba jeszcze nigdy nie głosowała, zapisz informację o niej w bazie oraz to, że właśnie zagłosowała na dane zdjęcie, a następnie faktycznie dodaj głos.

Etap 3

Zmodyfikuj endpoint do lajkowania po raz kolejny. Dodaj warunek, który zakłada, że dany adres IP jednak już jest w bazie. W takiej sytuacji dodaj kod sprawdzający, czy głosowano na wybrane zdjęcie. Jeśli nie, dodaj identyfikator zdjęcia do votes tego użytkownika oraz normalnie zwiększ liczbę głosów. Jeśli jednak okaże się, że już głosowano na to zdjęcie, to zwróć błąd serwera (500).

Od tej chwili serwer będzie przechowywał informacje jacy użytkownicy (o jakim IP) głosowali na zdjęcia, i na które dokładnie. Jeśli wykryje próbę zagłosowania na fotografię jeszcze raz, to po prostu zwróci błąd.

Do wykonania zadania możesz wykorzystać paczkę request-ip.

Wnioski

Luki dałoby się uniknąć, gdyby autor stosował się do dobrych praktyk. Podobnie jak wcześniej, twórca zapomniał o zasadzie ograniczonego zaufania względem klienta.

Ten submoduł powinien uświadomić Ci, że dla osoby obeznanej w temacie tego typu manipulacje są naprawdę łatwym zadaniem.

Zadanie: gotowe!

Stwórz nowe repozytorium na GitHubie i wyślij tam projekt, nad którym pracowaliśmy w niniejszym submodule. Na tym etapie powinien on być już zabezpieczony przed znalezionymi zagrożeniami, a więc wszystkie ćwiczenia powinny być na nim wykonane.

31.6. Podsumowanie

Moduł był dość nietypowy. Poprzednio ciągle poznawaliśmy nowe technologie, a tym razem nie uświadczyliśmy wielu nowości. Zamiast tego skupiliśmy się na czymś ważniejszym, czyli bezpieczeństwie. Dobra aplikacja to nie tylko taka, która zaoferuje wiele sprawnie działających funkcjonalności, dobra aplikacja przede wszystkim musi być bezpieczna.

Dopóki pracowaliśmy nad projektami do nauki, nie wiedzieliśmy, jak bardzo jest to ważne. W końcu, gdyby ktoś zaatakował którąś z naszych aplikacji, straty z tym związane byłyby niewielkie.

Staraliśmy się uświadomić Ci, jak łatwo można wyrządzić wiele krzywd słabo zabezpieczonej witrynie, gdy ma się chociaż trochę wiedzy na ten temat, a złych ludzi, czy wojującej konkurencji na rynku nigdy nie brakuje.

W swojej pracy będziesz zajmować się głównie komercyjnymi projektami, a tam bezpieczeństwo to temat numer jeden.

Jeśli zawiedziemy, możemy narazić klientów na wyciek wrażliwych danych, ogromne starty finansowe oraz wizerunkowe. Mimo że poruszyliśmy temat bezpieczeństwa tak późno w kursie, musisz traktować go jako priorytet przy budowaniu aplikacji.

31.7. Quiz powtórkowy

Na koniec przygotowaliśmy dla Ciebie quiz powtórkowy. Pomoże Ci on utrwalić wiedzę z poprzednich modułów.

Odpowiedzi nie są nigdzie zapisywane, więc pozostaną tylko do Twojej wiadomości. Ten quiz ma Ci posłużyć jako pomoc w nauce – dlatego pod każdym pytaniem znajdziesz guzik, który sprawdzi poprawność Twoich odpowiedzi oraz poda Ci wyjaśnienie poruszanego zagadnienia.

1. Pomówmy o testowaniu kodu w backendzie:

Wyjaśnienie

Testy w backendzie są tak samo ważne, jak te przeprowadzane po stronie klienta. Każda aplikacja webowa łączy w sobie te dwie dziedziny i działa poprawnie, tylko jeśli wszystkie elementy są napisane właściwie. Oczywiście możemy użyć narzędzi typu Postman czy MongoDB Compass, choć na dłuższą metę jest to uciążliwe i mozolne. Przy większej ilości zmian i elementów do sprawdzenia, o wiele lepszym rozwiązaniem jest tworzenie testów automatycznych, które (jeśli dobrze napisane) są równie wiarygodne, jak te "ręczne", a z pewnością wygodniejsze. Na testowanie w backendzie składa się m.in. sprawdzanie modeli Mongoose (jeśli używamy tej biblioteki), testowanie operacji CRUD oraz weryfikacja działania endpointów. Jeśli mamy taką potrzebę, możemy testować również działanie WebSocketów.

2. Załóżmy, że mamy funkcję cutText, która przyjmuje dwa argumenty: content i maxLength oraz zwraca błąd, gdy content nie jest stringiem.

if(typeof content !== 'string') return 'Error';

Wyjaśnienie

Każdy skrypt powinien być przetestowany i nie możemy zakładać z góry, że działa bezbłędnie. Testy w backendzie wykonujemy np. za pomocą pakietu Mocha, jednak jest on tylko task runnerem i nie ma wbudowanej biblioteki asercji. Dlatego, by skorzystać z dobrodziejstw funkcji expect, wraz z Mochą używa się też Chai. Same testy konstruujemy w bloku describe i powinniśmy w nich przekazywać do funkcji wszystkie wymagane argumenty. Jeśli chcemy przetestować, jak funkcja poradzi sobie przy ich braku, możemy zastosować undefined, np. expect(cutText(undefined, 20)).to.equal('Error');. W tym przykładzie pierwszy argument jest niezdefiniowany, a drugi właściwy (20). W przypadku tej funkcji nie wystarczy sprawdzenie jak poradzi sobie ze stringiem. Musimy jeszcze skontrolować co stanie się w sytuacji, gdy pojawią się inne typy danych, jak liczby czy obiekty.

3. Załóżmy, że nasz serwer będzie komunikował się z bazą danych MongoDB, a do modelowania danych użyliśmy Mongoose.

Wyjaśnienie

Testowanie schematu struktur danych to dobra praktyka. Zapewnia nas bowiem, że Mongoose będzie w stanie dbać o poprawność danych wprowadzanych do bazy, niemniej jednak w żaden sposób nie daje nam gwarancji, że sama komunikacja z nią działa bez zarzutów. Najlepszą praktyką jest praca poza bazą, która jest w użyciu, ponieważ wyniki testów mogą być zafałszowane, gdy użytkownicy dodają w czasie ich trwania swoje dane. Dlatego do testowania metod CRUD możemy posłużyć się symulacją bazy za pomocą np. mongodb-memory-server.

4. Testujemy endpointy.

Wyjaśnienie

Owszem, by symulować requesty musimy użyć funkcji request, ale nie jest ona wbudowana w Chai. Do tego potrzebujemy jeszcze pluginu o nazwie chai-http. By nie ingerować w kod serwera, możemy pracować nie na symulacji, a na prawdziwej bazie danych. Najbezpieczniej jednak korzystać z jej lokalnej kopii. Używanie hooków before i after znacznie ułatwi nam pracę i sprawi, że będziemy mogli przeprowadzać testy na danych, które sami wprowadzimy.

;